├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .prettierrc.js ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── bin └── web-and-app-activity-controls.js ├── locales ├── da-DK.json ├── de-DE.json ├── en-US.json ├── es-ES.json ├── fr-FR.json ├── hi-IN.json ├── id-ID.json ├── it-IT.json ├── ja-JP.json ├── ko-KR.json ├── nl-NL.json ├── no-NO.json ├── pl-PL.json ├── pt-BR.json ├── ru-RU.json ├── sv-SE.json ├── th-TH.json ├── tr-TR.json ├── zh-HK.json └── zh-TW.json ├── package.json ├── samples ├── example_test.ts └── skeleton_test.ts ├── src ├── action-on-google-test-manager.ts ├── actions-api-helper.ts ├── constants.ts ├── index.ts ├── merge.ts └── test │ ├── mock-response1.ts │ ├── test-data.ts │ └── test.ts ├── tsconfig.json └── typedoc.json /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/gts/" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.DS_Store 3 | *.log 4 | *.tgz 5 | dist 6 | package 7 | package-lock.json 8 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ...require('gts/.prettierrc.json') 3 | } -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to become a contributor and submit your own code 2 | 3 | ## Contributor License Agreements 4 | 5 | We'd love to accept your sample apps and patches! Before we can take them, we 6 | have to jump a couple of legal hurdles. 7 | 8 | Please fill out either the individual or corporate Contributor License Agreement 9 | (CLA). 10 | 11 | * If you are an individual writing original source code and you're sure you 12 | own the intellectual property, then you'll need to sign an [individual CLA](https://developers.google.com/open-source/cla/individual). 13 | * If you work for a company that wants to allow you to contribute your work, 14 | then you'll need to sign a [corporate CLA](https://developers.google.com/open-source/cla/corporate). 15 | 16 | Follow either of the two links above to access the appropriate CLA and 17 | instructions for how to sign and return it. Once we receive it, we'll be able to 18 | accept your pull requests. 19 | 20 | ## Contributing A Patch 21 | 22 | 1. Submit an issue describing your proposed change to the repo in question. 23 | 1. The repo owner will respond to your issue promptly. 24 | 1. If your proposed change is accepted, and you haven't already done so, sign a 25 | Contributor License Agreement (see details above). 26 | 1. Fork the desired repo, develop and test your code changes. 27 | 1. Ensure that your code adheres to the existing style in the library to which 28 | you are contributing. 29 | 1. Ensure that your code has an appropriate set of unit tests which all pass. 30 | 1. Submit a pull request. 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, and 10 | distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by the copyright 13 | owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all other entities 16 | that control, are controlled by, or are under common control with that entity. 17 | For the purposes of this definition, "control" means (i) the power, direct or 18 | indirect, to cause the direction or management of such entity, whether by 19 | contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the 20 | outstanding shares, or (iii) beneficial ownership of such entity. 21 | 22 | "You" (or "Your") shall mean an individual or Legal Entity exercising 23 | permissions granted by this License. 24 | 25 | "Source" form shall mean the preferred form for making modifications, including 26 | but not limited to software source code, documentation source, and configuration 27 | files. 28 | 29 | "Object" form shall mean any form resulting from mechanical transformation or 30 | translation of a Source form, including but not limited to compiled object code, 31 | generated documentation, and conversions to other media types. 32 | 33 | "Work" shall mean the work of authorship, whether in Source or Object form, made 34 | available under the License, as indicated by a copyright notice that is included 35 | in or attached to the work (an example is provided in the Appendix below). 36 | 37 | "Derivative Works" shall mean any work, whether in Source or Object form, that 38 | is based on (or derived from) the Work and for which the editorial revisions, 39 | annotations, elaborations, or other modifications represent, as a whole, an 40 | original work of authorship. For the purposes of this License, Derivative Works 41 | shall not include works that remain separable from, or merely link (or bind by 42 | name) to the interfaces of, the Work and Derivative Works thereof. 43 | 44 | "Contribution" shall mean any work of authorship, including the original version 45 | of the Work and any modifications or additions to that Work or Derivative Works 46 | thereof, that is intentionally submitted to Licensor for inclusion in the Work 47 | by the copyright owner or by an individual or Legal Entity authorized to submit 48 | on behalf of the copyright owner. For the purposes of this definition, 49 | "submitted" means any form of electronic, verbal, or written communication sent 50 | to the Licensor or its representatives, including but not limited to 51 | communication on electronic mailing lists, source code control systems, and 52 | issue tracking systems that are managed by, or on behalf of, the Licensor for 53 | the purpose of discussing and improving the Work, but excluding communication 54 | that is conspicuously marked or otherwise designated in writing by the copyright 55 | owner as "Not a Contribution." 56 | 57 | "Contributor" shall mean Licensor and any individual or Legal Entity on behalf 58 | of whom a Contribution has been received by Licensor and subsequently 59 | incorporated within the Work. 60 | 61 | 2. Grant of Copyright License. 62 | 63 | Subject to the terms and conditions of this License, each Contributor hereby 64 | grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, 65 | irrevocable copyright license to reproduce, prepare Derivative Works of, 66 | publicly display, publicly perform, sublicense, and distribute the Work and such 67 | Derivative Works in Source or Object form. 68 | 69 | 3. Grant of Patent License. 70 | 71 | Subject to the terms and conditions of this License, each Contributor hereby 72 | grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, 73 | irrevocable (except as stated in this section) patent license to make, have 74 | made, use, offer to sell, sell, import, and otherwise transfer the Work, where 75 | such license applies only to those patent claims licensable by such Contributor 76 | that are necessarily infringed by their Contribution(s) alone or by combination 77 | of their Contribution(s) with the Work to which such Contribution(s) was 78 | submitted. If You institute patent litigation against any entity (including a 79 | cross-claim or counterclaim in a lawsuit) alleging that the Work or a 80 | Contribution incorporated within the Work constitutes direct or contributory 81 | patent infringement, then any patent licenses granted to You under this License 82 | for that Work shall terminate as of the date such litigation is filed. 83 | 84 | 4. Redistribution. 85 | 86 | You may reproduce and distribute copies of the Work or Derivative Works thereof 87 | in any medium, with or without modifications, and in Source or Object form, 88 | provided that You meet the following conditions: 89 | 90 | You must give any other recipients of the Work or Derivative Works a copy of 91 | this License; and 92 | You must cause any modified files to carry prominent notices stating that You 93 | changed the files; and 94 | You must retain, in the Source form of any Derivative Works that You distribute, 95 | all copyright, patent, trademark, and attribution notices from the Source form 96 | of the Work, excluding those notices that do not pertain to any part of the 97 | Derivative Works; and 98 | If the Work includes a "NOTICE" text file as part of its distribution, then any 99 | Derivative Works that You distribute must include a readable copy of the 100 | attribution notices contained within such NOTICE file, excluding those notices 101 | that do not pertain to any part of the Derivative Works, in at least one of the 102 | following places: within a NOTICE text file distributed as part of the 103 | Derivative Works; within the Source form or documentation, if provided along 104 | with the Derivative Works; or, within a display generated by the Derivative 105 | Works, if and wherever such third-party notices normally appear. The contents of 106 | the NOTICE file are for informational purposes only and do not modify the 107 | License. You may add Your own attribution notices within Derivative Works that 108 | You distribute, alongside or as an addendum to the NOTICE text from the Work, 109 | provided that such additional attribution notices cannot be construed as 110 | modifying the License. 111 | You may add Your own copyright statement to Your modifications and may provide 112 | additional or different license terms and conditions for use, reproduction, or 113 | distribution of Your modifications, or for any such Derivative Works as a whole, 114 | provided Your use, reproduction, and distribution of the Work otherwise complies 115 | with the conditions stated in this License. 116 | 117 | 5. Submission of Contributions. 118 | 119 | Unless You explicitly state otherwise, any Contribution intentionally submitted 120 | for inclusion in the Work by You to the Licensor shall be under the terms and 121 | conditions of this License, without any additional terms or conditions. 122 | Notwithstanding the above, nothing herein shall supersede or modify the terms of 123 | any separate license agreement you may have executed with Licensor regarding 124 | such Contributions. 125 | 126 | 6. Trademarks. 127 | 128 | This License does not grant permission to use the trade names, trademarks, 129 | service marks, or product names of the Licensor, except as required for 130 | reasonable and customary use in describing the origin of the Work and 131 | reproducing the content of the NOTICE file. 132 | 133 | 7. Disclaimer of Warranty. 134 | 135 | Unless required by applicable law or agreed to in writing, Licensor provides the 136 | Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, 137 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, 138 | including, without limitation, any warranties or conditions of TITLE, 139 | NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are 140 | solely responsible for determining the appropriateness of using or 141 | redistributing the Work and assume any risks associated with Your exercise of 142 | permissions under this License. 143 | 144 | 8. Limitation of Liability. 145 | 146 | In no event and under no legal theory, whether in tort (including negligence), 147 | contract, or otherwise, unless required by applicable law (such as deliberate 148 | and grossly negligent acts) or agreed to in writing, shall any Contributor be 149 | liable to You for damages, including any direct, indirect, special, incidental, 150 | or consequential damages of any character arising as a result of this License or 151 | out of the use or inability to use the Work (including but not limited to 152 | damages for loss of goodwill, work stoppage, computer failure or malfunction, or 153 | any and all other commercial damages or losses), even if such Contributor has 154 | been advised of the possibility of such damages. 155 | 156 | 9. Accepting Warranty or Additional Liability. 157 | 158 | While redistributing the Work or Derivative Works thereof, You may choose to 159 | offer, and charge a fee for, acceptance of support, warranty, indemnity, or 160 | other liability obligations and/or rights consistent with this License. However, 161 | in accepting such obligations, You may act only on Your own behalf and on Your 162 | sole responsibility, not on behalf of any other Contributor, and only if You 163 | agree to indemnify, defend, and hold each Contributor harmless for any liability 164 | incurred by, or claims asserted against, such Contributor by reason of your 165 | accepting any such warranty or additional liability. 166 | 167 | END OF TERMS AND CONDITIONS 168 | 169 | APPENDIX: How to apply the Apache License to your work 170 | 171 | To apply the Apache License to your work, attach the following boilerplate 172 | notice, with the fields enclosed by brackets "[]" replaced with your own 173 | identifying information. (Don't include the brackets!) The text should be 174 | enclosed in the appropriate comment syntax for the file format. We also 175 | recommend that a file or class name and description of purpose be included on 176 | the same "printed page" as the copyright notice for easier identification within 177 | third-party archives. 178 | 179 | Copyright [yyyy] [name of copyright owner] 180 | 181 | Licensed under the Apache License, Version 2.0 (the "License"); 182 | you may not use this file except in compliance with the License. 183 | You may obtain a copy of the License at 184 | 185 | http://www.apache.org/licenses/LICENSE-2.0 186 | 187 | Unless required by applicable law or agreed to in writing, software 188 | distributed under the License is distributed on an "AS IS" BASIS, 189 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 190 | See the License for the specific language governing permissions and 191 | limitations under the License. 192 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Assistant Conversation Testing Library 2 | 3 | *:warning: Warning: Conversational Actions will be deprecated on June 13, 2023. For more information, see [Conversational Actions Sunset](https://goo.gle/ca-sunset).* 4 | 5 | 6 | This library provides an easy way to write automated tests for your Action. The library wraps the [Actions API](https://developers.google.com/assistant/actions/api), enabling you to define a test suite, send queries to your Action, and make assertions on the output to verify information specific to your Action's conversational state. 7 | 8 | ## Install 9 | 10 | ### Node 11 | The latest version of this library **requires Node v10.13.0 or later**. You can install the library with 12 | 13 | ``` 14 | npm install @assistant/conversation-testing --save 15 | ``` 16 | 17 | ## Setup 18 | 19 | 1. Enable the Actions API for your project (The Actions API is enabled by default for newly created projects): 20 | 1. Visit the [Google API console](https://console.developers.google.com/apis/library) and select your project from the **Select a project** dropdown. 21 | 1. If the Action API is not enabled, search for *"Actions API"* and click **Enable**. 22 | 1. Create a Service Account key: 23 | 1. Visit the [Google Cloud console credentials page](https://console.developers.google.com/apis/credentials) and select your project from the **Select a project** dropdown. 24 | 1. In the "Create credentials" click "Service account". 25 | 1. Enter a service account name and click **Create**. 26 | 1. From the **Select a role** dropdown, select **Actions > Actions Admin**. 27 | 1. Click **Continue**. 28 | 1. Click **ADD KEY**, then select **Create new key**, then press **CREATE** 29 | to download the service account JSON file. 30 | 1. Set the service account key file to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable: `export 31 | GOOGLE_APPLICATION_CREDENTIALS=/path/to/service_account.json` 32 | 33 | ## Usage 34 | 35 | Note: The examples below use [Mocha](https://mochajs.org/) as a testing 36 | framework. You can change the report style by overriding the Mocha report style. See more information in [Mocha's reporter docs](https://mochajs.org/#reporters) 37 | 38 | 1. Create file for your tests and define a test suite. 39 | 40 | ```javascript 41 | import 'mocha'; 42 | 43 | import {ActionsOnGoogleTestManager} from '@assistant/conversation-testing'; 44 | 45 | const PROJECT_ID = ''; 46 | const TRIGGER_PHRASE = 'Talk to '; 47 | 48 | describe('Test Suite', function() { 49 | // Set the timeout for each test run to 60s. 50 | this.timeout(60000); 51 | let testManager; 52 | 53 | before('Before all setup', async function() { 54 | testManager = new ActionsOnGoogleTestManager({ projectId: PROJECT_ID }); 55 | await testManager.writePreviewFromDraft(); 56 | testManager.setSuiteLocale(DEFAULT_LOCALE); 57 | testManager.setSuiteSurface(DEFAULT_SURFACE); 58 | }); 59 | 60 | afterEach(function() { 61 | testManager.cleanUpAfterTest(); 62 | }); 63 | }); 64 | ``` 65 | 66 | 1. Update the test suite to include various tests with queries and assertions related to your Action. Examples of updates include: 67 | 68 | + **Test your main invocation** - The following test is run to verify your invocation points to the intended `actions.intent.MAIN` intent, the correct scene is initialized, and the prompt for the scene is sent. 69 | 70 | ```javascript 71 | ... 72 | it('trigger only test', async function() { 73 | await testManager.sendQuery(TRIGGER_PHRASE); 74 | testManager.assertIntent('actions.intent.MAIN'); 75 | testManager.assertScene('Welcome'); 76 | testManager.assertSpeech('Welcome to Facts about Google!'); 77 | }); 78 | ... 79 | ``` 80 | 81 | + **Test your conversation** - The example below shows how you might test multiple conversational turns. This test sets the locale to use for the conversation, checks the main invocation, and responds to the Action, while checking matched intents, session parameters and expected responses. 82 | 83 | ```javascript 84 | ... 85 | it('main functionality', async function() { 86 | testManager.setTestLocale('en-GB'); 87 | await testManager.sendQuery(TRIGGER_PHRASE); 88 | testManager.assertIntent('actions.intent.MAIN'); 89 | testManager.assertScene('Welcome'); 90 | testManager.assertSpeech('Welcome to Facts about Google!'); 91 | testManager.assertText( 92 | 'Welcome to .* about Google!', {isRegexp: true, isExact: true}); 93 | await testManager.sendQuery('Cats'); 94 | testManager.assertSpeech(['Oh great, cats!', 'Good choice cats!']); 95 | testManager.assertIntent('cats'); 96 | testManager.assertSessionParam('categoryType', 'Cats'); 97 | await testManager.sendQuery('stop'); 98 | testManager.assertConversationEnded(); 99 | }); 100 | ... 101 | ``` 102 | 103 | + **Test intent matching** - This test asserts the expected intent is in the top number of matched intents to a given query, in the given language. In the example below, the test returns successful if the *yes* intent is in the top three intent matches, when the query *"yes, I do"* is sent. 104 | 105 | ```javascript 106 | ... 107 | it('intent match testing', async function() { 108 | await testManager.assertTopMatchedIntent('yes, I do', 'yes', 3, 'en'); 109 | }); 110 | ... 111 | ``` 112 | 113 | ## Running tests 114 | 115 | 1. Ensure you have set your service account key file to the `GOOGLE_APPLICATION_CREDENTIALS` environment variable: `export 116 | GOOGLE_APPLICATION_CREDENTIALS=/path/to/service_account.json` 117 | 118 | 1. Run: `npm install && npm run build && npm test` 119 | 120 | Note: You can use "npm test -- --bail" if you want to stop after the first 121 | failure. 122 | 123 | ## Supported features 124 | 125 | This library provides functions to control your conversation, easily assert 126 | aspects of the response, setting Suite and Test level defaults, and more. 127 | 128 | ### Send functions 129 | 130 | * `sendQuery(queryText)` - Sends a query to your action. 131 | * `sendStop()` - Sends a 'stop' query, to exit the action. 132 | 133 | ### Assertions 134 | 135 | * `assertSpeech(expected, assertParams?, response?)` - Asserts the expected 136 | text is contained in the response Speech (concatenation of the first_simple 137 | and last_simple Speech). Note: Through optional `assertParams` it is 138 | possible to require exact match, and/or allow regexp matching. 139 | * `assertText(expected, assertParams?, response?)` - Asserts the response Text 140 | (concatenation of the first_simple and last_simple Text) 141 | * `assertScene(expected, response?)` - Asserts the response Scene, i.e. the 142 | scene that is currently active in the conversation 143 | * `assertIntent(expected, response?)` - Asserts the response Intent, i.e. the 144 | intent that was matched by the query phrase 145 | * `assertConversationEnded(response?)` - Asserts that the conversation ended 146 | in the response. 147 | * `assertConversationNotEnded(response?)` - Asserts that the conversation did 148 | not end in the response. 149 | * `assertSessionParam(name, expected, response?)` - Asserts the response 150 | session storage parameter value. 151 | * `assertUserParam(name, expected, response?)` - Asserts the response user 152 | storage parameter value. 153 | * `assertHomeParam(name, expected, response?)` - Asserts the response home 154 | storage parameter value. 155 | * `assertCanvasURL(expected, response?)` - Asserts the Canvas URL. 156 | * `assertCanvasData(expected, requireExact?, response?)` - Asserts the Canvas 157 | Data. 158 | * `assertCard(expected, requireExact?, response?)` - Asserts the Card 159 | response. 160 | * `assertImage(expected, requireExact?, response?)` - Asserts the Image 161 | response. 162 | * `assertCollection(expected, requireExact?, response?)` - Asserts the 163 | Collection response. 164 | * `assertTable(expected, requireExact?, response?)` - Asserts the Table 165 | response. 166 | * `assertList(expected, requireExact?, response?)` - Asserts the List 167 | response. 168 | * `assertMedia(expected, requireExact?, response?)` - Asserts the Media 169 | response. 170 | 171 | **Note:** All `assertXXX` and `getXXX` functions get optional response as a last 172 | argument. If the response is not passed, the last turn response is used. 173 | 174 | **Note:** You can use your own custom assertions on values using 175 | [`Chai`](https://www.chaijs.com/), or any other node.js package, but the builtin 176 | assertions are likely to be more convenient for you. You can also access the 177 | full last turn response by calling `getLatestResponse()`, and run any custom 178 | checks on it. 179 | 180 | ### Getters 181 | 182 | * `getLatestResponse()` - Gets the latest turn full response. 183 | * `getSpeech(response?)` - Gets the response Speech (concatenation of the 184 | first_simple and last_simple Speech) 185 | * `getText(response?)` - Gets the response Text (concatenation of the 186 | first_simple and last_simple Text) 187 | * `getScene(response?)` - Gets the response Scene. 188 | * `getIntent(response?)` - Gets the response Intent. 189 | * `getIsConversationEnded(response?)` - Returns whether the conversation ended 190 | in the response. 191 | * `getSessionParam(name, response?)` - Gets the response value of a session 192 | storage parameter. 193 | * `getUserParam(name, response?)` - Gets the response value of a user storage 194 | parameter. 195 | * `getHomeParam(name, response?)` - Gets the response value of a home storage 196 | parameter. 197 | * `getCanvasURL(response?)` - Gets the Canvas URL. 198 | * `getCanvasData(response?)` - Gets the Canvas Data. 199 | * `getContent(response?)` - Gets the Prompt Content, if exists. 200 | * `getCard(response?)` - Gets the Prompt Card, if exists. 201 | * `getCollection(response?)` - Gets the Prompt Collection, if exists. 202 | * `getImage(response?)` - Gets the Prompt Image, if exists. 203 | * `getTable(response?)` - Gets the Prompt Table, if exists. 204 | * `getList(response?)` - Gets the Prompt List, if exists. 205 | * `getMedia(response?)` - Gets the Prompt Media, if exists. 206 | 207 | ### Match Intents 208 | 209 | This are assertions that are run as standalone NLU versification (not in the 210 | context of conversation): 211 | 212 | * `assertTopMatchedIntent(query, expectedIntent, place, queryLanguage)` - 213 | Asserts the expected intent is in the top N matched intent to a given query, 214 | in the given language. 215 | * `assertMatchIntentsFromYamlFile(yamlFile, queriesLanguage?)` - Checks all 216 | the queries intent assertions in the yaml file. 217 | * `getMatchIntentsList(query, queryLanguage)` - Gets the matched intents' 218 | names using the matchIntents API call. 219 | * `getMatchIntentsList(query, queryLanguage)` - Gets the intents for the 220 | checked query using the matchIntents API call. 221 | 222 | **Note:** Make sure to set the language code and NOT locale code as the 223 | queryLanguage. 224 | 225 | ## Troubleshooting 226 | 227 | * If `updatePreview` is failing on *'callUpdatePreview: Precondition check 228 | failed'*, verify that you are able to simulate your Action in the console simulator. 229 | 230 | ## Known issues 231 | 232 | * Implicit invocation methods, like built-in intents, cannot be tested. 233 | * Testing transactions is not yet supported. 234 | * Testing `NO_INPUT` events is not supported. 235 | 236 | ## References & Issues 237 | + Questions? Go to [StackOverflow](https://stackoverflow.com/questions/tagged/actions-on-google) or [Assistant Developer Community on Reddit](https://www.reddit.com/r/GoogleAssistantDev/). 238 | + For bugs, please report an issue on Github. 239 | + Actions on Google [Documentation](https://developers.google.com/assistant) 240 | + Actions on Google [Codelabs](https://codelabs.developers.google.com/?cat=Assistant). 241 | 242 | ## Make Contributions 243 | Please read and follow the steps in the [CONTRIBUTING.md](CONTRIBUTING.md). 244 | 245 | ## License 246 | See [LICENSE](LICENSE). 247 | -------------------------------------------------------------------------------- /bin/web-and-app-activity-controls.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /** 3 | * Copyright 2020 Google LLC 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * https://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | * 17 | */ 18 | /* eslint-disable no-process-exit */ 19 | 'use strict'; 20 | const {ActionsTestingClient} = require('@assistant/actions'); 21 | /** 22 | * Enable Web & App Activity Controls on a service account. 23 | * 24 | * It is necessary to have this setting enabled in order to call the Actions API. 25 | * The setting is originally disabled for service accounts, and it is 26 | * preserved until set to a different value. This means it only needs to be 27 | * enabled once per account (and not necessarily once per test), unless it is 28 | * later disabled. 29 | * 30 | * For user accounts it is possible to change this setting via the Activity Controls page. 31 | * See: https://support.google.com/websearch/answer/54068 32 | * 33 | * Expected usage: 34 | * To enable: node web-and-app-activity-controls.js --enable 35 | * To disable: node web-and-app-activity-controls.js --disable 36 | */ 37 | if (!('GOOGLE_APPLICATION_CREDENTIALS' in process.env)) { 38 | console.error('Service account key file not found.'); 39 | console.error( 40 | 'Store the path to your service account key file in the ' + 41 | 'GOOGLE_APPLICATION_CREDENTIALS environment variable.' 42 | ); 43 | process.exit(1); 44 | } 45 | let flag = '--enable'; 46 | if (process.argv.length > 3) { 47 | console.error('Incorrect number of arguments.'); 48 | console.error( 49 | 'Usage:\n' + 50 | '\tnode activity-controls.js --enable\n' + 51 | '\tnode activity-controls.js --disable' 52 | ); 53 | process.exit(1); 54 | } else if (process.argv.length === 3) { 55 | // Flag should be --enable or --disable. 56 | flag = process.argv[2]; 57 | if (flag !== '--enable' && flag !== '--disable') { 58 | console.error(`Invalid argument ${flag}`); 59 | process.exit(1); 60 | } 61 | } 62 | const enabled = flag === '--enable'; 63 | const client = new ActionsTestingClient(); 64 | client.setWebAndAppActivityControl({enabled}); 65 | console.log(`setWebAndAppActivityControl ${enabled ? 'enabled' : 'disabled'}`); 66 | -------------------------------------------------------------------------------- /locales/da-DK.json: -------------------------------------------------------------------------------- 1 | { 2 | "cancel": "cancel" 3 | } 4 | -------------------------------------------------------------------------------- /locales/de-DE.json: -------------------------------------------------------------------------------- 1 | { 2 | "cancel": "cancel" 3 | } 4 | -------------------------------------------------------------------------------- /locales/en-US.json: -------------------------------------------------------------------------------- 1 | { 2 | "cancel": "cancel" 3 | } 4 | -------------------------------------------------------------------------------- /locales/es-ES.json: -------------------------------------------------------------------------------- 1 | { 2 | "cancel": "cancel" 3 | } 4 | -------------------------------------------------------------------------------- /locales/fr-FR.json: -------------------------------------------------------------------------------- 1 | { 2 | "cancel": "annuler" 3 | } 4 | -------------------------------------------------------------------------------- /locales/hi-IN.json: -------------------------------------------------------------------------------- 1 | { 2 | "cancel": "cancel" 3 | } 4 | -------------------------------------------------------------------------------- /locales/id-ID.json: -------------------------------------------------------------------------------- 1 | { 2 | "cancel": "cancel" 3 | } 4 | -------------------------------------------------------------------------------- /locales/it-IT.json: -------------------------------------------------------------------------------- 1 | { 2 | "cancel": "cancel" 3 | } 4 | -------------------------------------------------------------------------------- /locales/ja-JP.json: -------------------------------------------------------------------------------- 1 | { 2 | "cancel": "cancel" 3 | } 4 | -------------------------------------------------------------------------------- /locales/ko-KR.json: -------------------------------------------------------------------------------- 1 | { 2 | "cancel": "취소" 3 | } 4 | -------------------------------------------------------------------------------- /locales/nl-NL.json: -------------------------------------------------------------------------------- 1 | { 2 | "cancel": "cancel" 3 | } 4 | -------------------------------------------------------------------------------- /locales/no-NO.json: -------------------------------------------------------------------------------- 1 | { 2 | "cancel": "cancel" 3 | } 4 | -------------------------------------------------------------------------------- /locales/pl-PL.json: -------------------------------------------------------------------------------- 1 | { 2 | "cancel": "Anuluj" 3 | } 4 | -------------------------------------------------------------------------------- /locales/pt-BR.json: -------------------------------------------------------------------------------- 1 | { 2 | "cancel": "cancelar" 3 | } 4 | -------------------------------------------------------------------------------- /locales/ru-RU.json: -------------------------------------------------------------------------------- 1 | { 2 | "cancel": "отмена" 3 | } 4 | -------------------------------------------------------------------------------- /locales/sv-SE.json: -------------------------------------------------------------------------------- 1 | { 2 | "cancel": "cancel" 3 | } 4 | -------------------------------------------------------------------------------- /locales/th-TH.json: -------------------------------------------------------------------------------- 1 | { 2 | "cancel": "cancel" 3 | } 4 | -------------------------------------------------------------------------------- /locales/tr-TR.json: -------------------------------------------------------------------------------- 1 | { 2 | "cancel": "İptal" 3 | } 4 | -------------------------------------------------------------------------------- /locales/zh-HK.json: -------------------------------------------------------------------------------- 1 | { 2 | "cancel": "取消" 3 | } 4 | -------------------------------------------------------------------------------- /locales/zh-TW.json: -------------------------------------------------------------------------------- 1 | { 2 | "cancel": "取消" 3 | } 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@assistant/conversation-testing", 3 | "description": "This is an end-to-end testing library for developers building actions", 4 | "version": "1.0.0", 5 | "license": "Apache-2.0", 6 | "author": "Google Inc.", 7 | "directories": { 8 | "lib": "./dist", 9 | "src": "./src", 10 | "test": "./src" 11 | }, 12 | "main": "dist/index.js", 13 | "types": "dist/index.d.ts", 14 | "engines": { 15 | "node": ">=10.13.0" 16 | }, 17 | "scripts": { 18 | "prepublish": "npm run compile", 19 | "test": "mocha --recursive --require ts-node/register src/test/*.ts", 20 | "compile": "tsc -p tsconfig.json", 21 | "lint": "gts check", 22 | "clean": "rm -rf dist docs && gts clean", 23 | "build": "tsc && rm -fr ./dist/locales && cp -r ./locales ./dist", 24 | "docs": "typedoc --options typedoc.json", 25 | "docs:clean": "rm -rf docs && mkdir docs && touch docs/.nojekyll && yarn docs", 26 | "check": "gts check", 27 | "fix": "gts fix", 28 | "prepare": "npm run compile", 29 | "pretest": "npm run compile", 30 | "posttest": "npm run check" 31 | }, 32 | "repository": { 33 | "type": "git", 34 | "url": "git+https://github.com/actions-on-google/assistant-conversation-testing-nodejs.git" 35 | }, 36 | "bugs": { 37 | "url": "https://github.com/actions-on-google/assistant-conversation-testing-nodejs/issues" 38 | }, 39 | "homepage": "https://github.com/actions-on-google/assistant-conversation-testing-nodejs#readme", 40 | "keywords": [ 41 | "google", 42 | "google actions", 43 | "google assistant", 44 | "automated testing", 45 | "continuous integration" 46 | ], 47 | "bin": { 48 | "web-and-app-activity-controls": "./bin/web-and-app-activity-controls.js" 49 | }, 50 | "dependencies": { 51 | "@assistant/actions": "0.1.0", 52 | "@types/chai": "^4.1.4", 53 | "@types/i18n": "^0.8.6", 54 | "@types/js-yaml": "^3.12.5", 55 | "@types/node": "^10.9.4", 56 | "@types/promise.prototype.finally": "^2.0.3", 57 | "chai": "^4.2.0", 58 | "google-auth-library": "^6.1.2", 59 | "grpc": "^1.24.0", 60 | "i18n": "^0.8.3", 61 | "js-yaml": "^3.14.0", 62 | "promise.prototype.finally": "^3.1.1", 63 | "ts-node": "^7.0.1" 64 | }, 65 | "devDependencies": { 66 | "@types/mocha": "^8.0.0", 67 | "@types/winston": "^2.4.4", 68 | "gts": "^3.0.2", 69 | "mocha": "^8.0.1", 70 | "typedoc": "^0.15.0", 71 | "typescript": "^3.8.3", 72 | "@types/node": "^13.11.1" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /samples/example_test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * 15 | */ 16 | /* eslint-disable node/no-unpublished-import */ 17 | /* eslint-disable prefer-arrow-callback */ 18 | 19 | import 'mocha'; 20 | import {ActionsOnGoogleTestManager} from '@assistant/conversation-testing'; 21 | 22 | const PROJECT_ID = 'PROJECT_ID'; // '__project_id__' 23 | const TRIGGER_PHRASE = 24 | 'Talk to my test app'; //'Talk to __action_trigger_phrase__' 25 | 26 | const DEFAULT_LOCALE = 'en-US'; 27 | const DEFAULT_SURFACE = 'PHONE'; 28 | 29 | describe('Action project', function () { 30 | // Set the timeout for each test run to 60s. 31 | this.timeout(60000); 32 | let test: ActionsOnGoogleTestManager; 33 | 34 | before('setup test suite', async function() { 35 | test = new ActionsOnGoogleTestManager({ projectId: PROJECT_ID }); 36 | await test.writePreviewFromDraft(); 37 | test.setSuiteLocale(DEFAULT_LOCALE); 38 | test.setSuiteSurface(DEFAULT_SURFACE); 39 | }); 40 | 41 | afterEach('clean up test', function () { 42 | test.cleanUpAfterTest(); 43 | }); 44 | 45 | // Happy path test 46 | it('should match letter intent, and end the conversation', async function () { 47 | await test.sendQuery(TRIGGER_PHRASE); 48 | test.assertSpeech('Welcome to the game, how are you?'); 49 | test.assertSpeech('choose a letter please from A to Z'); 50 | test.assertSpeech('.* a letter .*', {isRegexp: true}); 51 | test.assertIntent('actions.intent.MAIN'); 52 | test.assertScene('question'); 53 | await test.sendQuery('letter C'); 54 | test.assertSpeech(['Good choice!', 'Great choice!']); 55 | test.assertCanvasData([{letter: 'C'}]); 56 | test.assertCanvasData([{letter: 'C'}], true); 57 | test.assertIntent('LETTER'); 58 | test.assertUserParam('alphabets', 'C'); 59 | await test.sendStop(); 60 | test.assertConversationEnded(); 61 | }); 62 | 63 | // Decline path test 64 | it('should match main intent, and end the conversation', async function () { 65 | await test.sendQuery(TRIGGER_PHRASE); 66 | test.assertSpeech('Welcome to the game, how are you?'); 67 | test.assertText('Welcome to the game, how are you?'); 68 | test.assertIntent('actions.intent.MAIN'); 69 | test.assertScene('question'); 70 | await test.sendQuery('no'); 71 | test.assertSpeech('Ok please come back later, when you are ready to play!'); 72 | test.assertConversationEnded(); 73 | }); 74 | 75 | // Decline path test 76 | it('should match letter intent, with intent parameter', async function () { 77 | await test.sendQuery(TRIGGER_PHRASE); 78 | test.assertIntent('actions.intent.MAIN'); 79 | test.assertScene('question'); 80 | await test.sendQuery('choose letter a'); 81 | test.assertIntent('CHOSE_A_LETTER'); 82 | test.assertSpeech('Great global intent letter choice.'); 83 | test.assertIntentParameter('letter', 'A'); 84 | }); 85 | 86 | // Help path test on phone 87 | it('should match help intent 3 times, and track count in session parameter', async function () { 88 | test.setTestSurface('PHONE'); 89 | await test.sendQuery(TRIGGER_PHRASE); 90 | test.assertSpeech('Welcome to the game, how are you?'); 91 | test.assertText(['Welcome to the game, how are you?']); 92 | test.assertIntent('actions.intent.MAIN'); 93 | test.assertScene('question'); 94 | await test.sendQuery('help'); 95 | test.assertSpeech('sure! here is some helpful information'); 96 | // Should increment 'helpCount' parameter for each time user says 'help' 97 | test.assertSessionParam('helpCount', 1); 98 | test.assertConversationNotEnded(); 99 | await test.sendQuery('help'); 100 | test.assertSessionParam('helpCount', 2); 101 | await test.sendQuery('help'); 102 | test.assertSessionParam('helpCount', 3); 103 | // Should exit if user is asking for help more than three times 104 | test.assertConversationEnded(); 105 | }); 106 | 107 | // Fallback request test 108 | it('should fallback three times', async function () { 109 | test.setTestSurface('SMART_DISPLAY'); 110 | await test.sendQuery(TRIGGER_PHRASE); 111 | test.assertIntent('actions.intent.MAIN'); 112 | test.assertScene('question'); 113 | await test.sendQuery('random request'); 114 | test.assertIntent('actions.intent.NO_MATCH_1'); 115 | test.assertSpeech("Sorry, I didn't catch that. Can you try again?"); 116 | await test.sendQuery('random request'); 117 | test.assertIntent('actions.intent.NO_MATCH_2'); 118 | test.assertSpeech("Sorry, I didn't catch that. Can you try again?"); 119 | await test.sendQuery('random request'); 120 | test.assertIntent('actions.intent.NO_MATCH_FINAL'); 121 | test.assertConversationEnded(); 122 | }); 123 | 124 | // Intent matching test (not e2e test) 125 | it('should assert top matched help intent', async function () { 126 | await test.assertTopMatchedIntent('help', 'HELP', 2, 'en'); 127 | }); 128 | }); 129 | -------------------------------------------------------------------------------- /samples/skeleton_test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * 15 | */ 16 | /* eslint-disable node/no-unpublished-import */ 17 | /* eslint-disable prefer-arrow-callback */ 18 | 19 | import 'mocha'; 20 | import {ActionsOnGoogleTestManager} from '@assistant/conversation-testing'; 21 | 22 | const PROJECT_ID = 'PROJECT_ID'; // Replace this with your project id. 23 | const TRIGGER_PHRASE = 24 | 'Talk to my test app'; // Replace this with your action trigger phrase. 25 | 26 | const DEFAULT_LOCALE = 'en-US'; 27 | const DEFAULT_SURFACE = 'SMART_DISPLAY'; 28 | 29 | describe('Action project', function () { 30 | // Set the timeout for each test run to 60s. 31 | this.timeout(60000); 32 | let test: ActionsOnGoogleTestManager; 33 | 34 | before('set up test suite', async function() { 35 | test = new ActionsOnGoogleTestManager({ projectId: PROJECT_ID }); 36 | await test.writePreviewFromDraft(); 37 | test.setSuiteLocale(DEFAULT_LOCALE); 38 | test.setSuiteSurface(DEFAULT_SURFACE); 39 | }); 40 | 41 | afterEach('clean up test', function () { 42 | test.cleanUpAfterTest(); 43 | }); 44 | 45 | // Trigger test 46 | it('should trigger action', async function () { 47 | await test.sendQuery(TRIGGER_PHRASE); 48 | test.assertIntent('actions.intent.MAIN'); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/action-on-google-test-manager.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | /** 17 | * @fileoverview Implementation of test handler around the Actions API, used to 18 | * setup and conveniently create tests. 19 | */ 20 | /* eslint-disable @typescript-eslint/no-explicit-any */ 21 | import {protos} from '@assistant/actions'; 22 | import {assert, expect} from 'chai'; 23 | import * as fs from 'fs'; 24 | import * as i18n from 'i18n'; 25 | import * as yaml from 'js-yaml'; 26 | 27 | import {ActionsApiHelper} from './actions-api-helper'; 28 | import * as constants from './constants'; 29 | import {getDeepMerge} from './merge'; 30 | 31 | const CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE = 32 | 'cannot be called before first query'; 33 | 34 | i18n.configure({ 35 | locales: constants.SUPPORTED_LOCALES, 36 | fallbacks: constants.FALLBACK_LOCALES, 37 | directory: __dirname + '/locales', 38 | defaultLocale: constants.DEFAULT_LOCALE, 39 | }); 40 | 41 | /** Map that controls the assert's comparing mode. */ 42 | interface AssertValueArgs { 43 | /** Whether an exact match is expected. Default is false. */ 44 | isExact?: boolean; 45 | /** Whether to do a regexp match. Default is false. */ 46 | isRegexp?: boolean; 47 | } 48 | 49 | /** Format of MatchIntent 'suite' of test cases. */ 50 | interface MatchIntentsTestSuite { 51 | /** Optional. the locale of the tested queries. */ 52 | defaultLanguage?: string; 53 | /** The match intent test cases. */ 54 | testCases?: MatchIntentsTestCase[]; 55 | } 56 | 57 | /** A MatchIntent test case. */ 58 | interface MatchIntentsTestCase { 59 | /** the checked query. */ 60 | query: string; 61 | /** the expected top matched intent. */ 62 | expectedIntent: string; 63 | } 64 | 65 | // boolValue, numberValue, stringValue, nullValue. 66 | type IValueSimple = boolean | number | string | null; 67 | 68 | // Same as above but also listValue and structValue. 69 | // Because of the modes of input, we can assume that the type is not infinitely nested. 70 | type IValueType = IValueSimple | IValueSimple[] | Record; 71 | 72 | /** 73 | * Obtains the typed-result from a struct field, recursively if needed 74 | * 75 | * @param resolvedField Struct field 76 | */ 77 | function getValueFromField( 78 | resolvedField: protos.google.protobuf.IValue 79 | ): IValueType { 80 | if ('boolValue' in resolvedField) { 81 | return resolvedField.boolValue!; 82 | } 83 | if ('numberValue' in resolvedField) { 84 | return resolvedField.numberValue!; 85 | } 86 | if ('stringValue' in resolvedField) { 87 | return resolvedField.stringValue!; 88 | } 89 | if ('nullValue' in resolvedField) { 90 | return resolvedField.nullValue!; 91 | } 92 | if ('listValue' in resolvedField) { 93 | // Recursively process listValues 94 | const listValue = resolvedField.listValue!; 95 | const list: IValueSimple[] = []; 96 | listValue.values?.forEach(value => { 97 | list.push(getValueFromField(value) as IValueSimple); 98 | }); 99 | return list; 100 | } 101 | if ('structValue' in resolvedField) { 102 | const structValue = resolvedField.structValue!; 103 | const entries = Object.entries(structValue.fields!); 104 | const map: Record = {}; 105 | entries.forEach(([key, value]) => { 106 | map[key] = getValueFromField(value) as IValueSimple; 107 | }); 108 | return map; 109 | } 110 | return null; 111 | } 112 | 113 | /** Test suite configuration interface. */ 114 | export interface TestSuiteConfig { 115 | /** the tested project ID. */ 116 | projectId: string; 117 | /** optional override of the suite default interaction params. */ 118 | interactionParams?: protos.google.actions.sdk.v2.ISendInteractionRequest; 119 | /** optional custom actions API endpoint. */ 120 | actionsApiCustomEndpoint?: string; 121 | } 122 | 123 | /** 124 | * A class implementing a testing framework wrapping manager class. 125 | */ 126 | export class ActionsOnGoogleTestManager { 127 | actionsApiHelper: ActionsApiHelper; 128 | latestResponse: protos.google.actions.sdk.v2.ISendInteractionResponse | null = null; 129 | suiteInteractionDefaults: protos.google.actions.sdk.v2.ISendInteractionRequest = 130 | constants.DEFAULT_INTERACTION_SETTING; 131 | testInteractionDefaults: protos.google.actions.sdk.v2.ISendInteractionRequest = {}; 132 | lastUserQuery: string | null | undefined = null; 133 | 134 | /** 135 | * Sets up all the needed objects and settings of a Suite. 136 | */ 137 | constructor({ 138 | projectId, 139 | interactionParams = {}, 140 | actionsApiCustomEndpoint, 141 | }: TestSuiteConfig) { 142 | this.updateSuiteInteractionDefaults(interactionParams); 143 | this.cleanUpAfterTest(); 144 | this.actionsApiHelper = new ActionsApiHelper({ 145 | projectId, 146 | actionsApiCustomEndpoint, 147 | }); 148 | } 149 | 150 | /** 151 | * Cleans up the test scenario temporary artifacts. Should run after each 152 | * test scenario. 153 | */ 154 | cleanUpAfterTest() { 155 | this.lastUserQuery = null; 156 | this.latestResponse = null; 157 | this.testInteractionDefaults = {}; 158 | } 159 | 160 | /** Send a query to your action */ 161 | sendQuery( 162 | queryText: string 163 | ): Promise { 164 | console.info(`--- sendQuery called with '${queryText}'`); 165 | return this.sendInteraction({input: {query: queryText}}); 166 | } 167 | 168 | /** Send an interaction object to your action */ 169 | async sendInteraction( 170 | interactionParams: protos.google.actions.sdk.v2.ISendInteractionRequest 171 | ): Promise { 172 | const interactionMergeParams = getDeepMerge( 173 | this.getTestInteractionMergedDefaults(), 174 | interactionParams 175 | ); 176 | // Set the conversation token - if not the first query 177 | if (this.latestResponse) { 178 | assert.isFalse( 179 | this.getIsConversationEnded(), 180 | 'Conversation ended unexpectedly in previous query.' 181 | ); 182 | interactionMergeParams[constants.TOKEN_FIELD_NAME] = this.latestResponse[ 183 | constants.TOKEN_FIELD_NAME 184 | ]; 185 | } 186 | this.lastUserQuery = interactionMergeParams.input!['query']; 187 | this.latestResponse = await this.actionsApiHelper.sendInteraction( 188 | interactionMergeParams 189 | ); 190 | this.validateSendInteractionResponse(this.latestResponse); 191 | return this.latestResponse!; 192 | } 193 | 194 | /** Send a 'stop' query, to stop/exit the action. */ 195 | sendStop() { 196 | return this.sendQuery(this.getStopQuery()); 197 | } 198 | 199 | /** Calls the 'writePreview' API method from draft. */ 200 | async writePreviewFromDraft() { 201 | console.info('Starting writePreview From Draft'); 202 | await this.actionsApiHelper.writePreviewFromDraft(); 203 | console.info('writePreview From Draft completed'); 204 | } 205 | 206 | /** Calls the 'writePreview' API method from submitted version number. */ 207 | async writePreviewFromVersion(versionNumber: number) { 208 | console.info(`Starting writePreview From Version ${versionNumber}`); 209 | await this.actionsApiHelper.writePreviewFromVersion(versionNumber); 210 | console.info('writePreview From Version completed'); 211 | } 212 | 213 | // -------------- Update/Set query params 214 | /** Overrides the suite interaction defaults. */ 215 | setSuiteInteractionDefaults( 216 | interactionParams: protos.google.actions.sdk.v2.ISendInteractionRequest 217 | ) { 218 | this.suiteInteractionDefaults = interactionParams; 219 | } 220 | 221 | /** Updates the suite interaction defaults. */ 222 | updateSuiteInteractionDefaults( 223 | interactionParams: protos.google.actions.sdk.v2.ISendInteractionRequest 224 | ) { 225 | this.suiteInteractionDefaults = getDeepMerge( 226 | this.suiteInteractionDefaults, 227 | interactionParams 228 | ); 229 | } 230 | 231 | // Update/Set query params 232 | /** Sets the default locale for the suite. */ 233 | setSuiteLocale(locale: string) { 234 | this.updateSuiteInteractionDefaults({deviceProperties: {locale}}); 235 | this.updateCurrentLocale(locale); 236 | } 237 | 238 | /** Sets the default surface for the suite. */ 239 | setSuiteSurface(surface: string) { 240 | const devicePropertiesSurface = surface as keyof typeof protos.google.actions.sdk.v2.DeviceProperties.Surface; 241 | this.updateSuiteInteractionDefaults({ 242 | deviceProperties: {surface: devicePropertiesSurface}, 243 | }); 244 | } 245 | 246 | // Update/Set query params 247 | /** 248 | * Sets the default locale for the current test scenario. Only needed for 249 | * tests that are for different locales from the suite locale. 250 | */ 251 | setTestLocale(locale: string) { 252 | this.updateTestInteractionDefaults({deviceProperties: {locale}}); 253 | this.updateCurrentLocale(locale); 254 | } 255 | 256 | /** 257 | * Sets the default surface for the current test scenario. Only needed for 258 | * tests that are for different surface from the suite surface. 259 | */ 260 | setTestSurface(surface: string) { 261 | const devicePropertiesSurface = surface as keyof typeof protos.google.actions.sdk.v2.DeviceProperties.Surface; 262 | this.updateTestInteractionDefaults({ 263 | deviceProperties: {surface: devicePropertiesSurface}, 264 | }); 265 | } 266 | 267 | /** Overrides the test scenario interaction defaults. */ 268 | setTestInteractionDefaults( 269 | interactionParams: protos.google.actions.sdk.v2.ISendInteractionRequest 270 | ) { 271 | this.testInteractionDefaults = interactionParams; 272 | } 273 | 274 | /** Updates the test scenario interaction defaults. */ 275 | updateTestInteractionDefaults( 276 | interactionParams: protos.google.actions.sdk.v2.ISendInteractionRequest 277 | ) { 278 | this.testInteractionDefaults = getDeepMerge( 279 | this.testInteractionDefaults, 280 | interactionParams 281 | ); 282 | } 283 | 284 | /** Returns the test scenario interaction defaults. */ 285 | getTestInteractionMergedDefaults(): protos.google.actions.sdk.v2.ISendInteractionRequest { 286 | return getDeepMerge( 287 | this.suiteInteractionDefaults, 288 | this.testInteractionDefaults 289 | ); 290 | } 291 | 292 | // --------------- Asserts From Response: 293 | /** 294 | * Asserts the response Speech (concatenation of the first_simple and 295 | * last_simple Speech) 296 | */ 297 | assertSpeech( 298 | expected: string | string[], 299 | assertParams: AssertValueArgs = {}, 300 | response?: protos.google.actions.sdk.v2.ISendInteractionResponse 301 | ) { 302 | const checkedResponse = response || this.latestResponse; 303 | expect( 304 | checkedResponse, 305 | `assertSpeech ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}` 306 | ).to.exist; 307 | const speech = this.getSpeech(checkedResponse!); 308 | assert.isDefined( 309 | speech, 310 | 'Speech field is missing from the last response: ' + 311 | JSON.stringify(speech) 312 | ); 313 | this.assertValueCommon(speech!, expected, 'speech', assertParams); 314 | } 315 | 316 | /** 317 | * Asserts the response Text (concatenation of the first_simple and 318 | * last_simple Text) 319 | */ 320 | assertText( 321 | expected: string | string[], 322 | assertParams: AssertValueArgs = {}, 323 | response?: protos.google.actions.sdk.v2.ISendInteractionResponse 324 | ) { 325 | const checkedResponse = response || this.latestResponse; 326 | expect( 327 | checkedResponse, 328 | `assertText ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}` 329 | ).to.exist; 330 | 331 | const text = this.getText(checkedResponse!); 332 | assert.isDefined( 333 | text, 334 | 'Text field is missing from the last response: ' + JSON.stringify(text) 335 | ); 336 | this.assertValueCommon(text!, expected, 'text', assertParams); 337 | } 338 | 339 | /** Asserts the response's Intent. */ 340 | assertIntent( 341 | expected: string, 342 | response?: protos.google.actions.sdk.v2.ISendInteractionResponse 343 | ) { 344 | const checkedResponse = response || this.latestResponse; 345 | expect( 346 | checkedResponse, 347 | `assertIntent ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}` 348 | ).to.exist; 349 | const intentName = this.getIntent(checkedResponse!); 350 | assert.equal(intentName, expected, 'Unexpected intent.'); 351 | } 352 | 353 | /** Asserts a response's Intent Parameter value. */ 354 | assertIntentParameter( 355 | parameterName: string, 356 | expected: any, 357 | response?: protos.google.actions.sdk.v2.ISendInteractionResponse 358 | ) { 359 | const checkedResponse = response || this.latestResponse; 360 | expect( 361 | checkedResponse, 362 | `assertIntentParameter ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}` 363 | ).to.exist; 364 | const parameterValue = this.getIntentParameter( 365 | parameterName, 366 | checkedResponse! 367 | ); 368 | assert.exists( 369 | parameterValue, 370 | `Intent parameter ${parameterValue} has no value.` 371 | ); 372 | assert.deepEqual( 373 | parameterValue, 374 | expected, 375 | 'Unexpected intent parameter value.' 376 | ); 377 | } 378 | 379 | /** Asserts the response's Last Scene. */ 380 | assertScene( 381 | expected: string, 382 | response?: protos.google.actions.sdk.v2.ISendInteractionResponse 383 | ) { 384 | const checkedResponse = response || this.latestResponse; 385 | expect( 386 | checkedResponse, 387 | `assertScene ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}` 388 | ).to.exist; 389 | const sceneName = this.getScene(checkedResponse!); 390 | assert.equal(sceneName, expected); 391 | } 392 | 393 | /** Asserts the prompt response. */ 394 | assertPrompt( 395 | expected: protos.google.actions.sdk.v2.conversation.IPrompt, 396 | requireExact = false, 397 | response?: protos.google.actions.sdk.v2.ISendInteractionResponse 398 | ) { 399 | const checkedResponse = response || this.latestResponse; 400 | expect( 401 | checkedResponse, 402 | `assertPrompt ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}` 403 | ).to.exist; 404 | const prompt = this.getPrompt(checkedResponse!); 405 | if (requireExact) { 406 | assert.deepEqual(prompt, expected); 407 | } else { 408 | assert.deepOwnInclude(prompt, expected); 409 | } 410 | } 411 | 412 | /** Asserts the Suggestion Chips. */ 413 | assertSuggestions( 414 | expected: protos.google.actions.sdk.v2.conversation.ISuggestion[], 415 | requireExact = false, 416 | response?: protos.google.actions.sdk.v2.ISendInteractionResponse 417 | ) { 418 | const checkedResponse = response || this.latestResponse; 419 | expect( 420 | checkedResponse, 421 | `assertSuggestions ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}` 422 | ).to.exist; 423 | const suggestions = this.getSuggestions(checkedResponse!); 424 | assert.exists(suggestions); 425 | assert.equal(suggestions!.length, expected.length); 426 | // Note: since deepEqual and deepOwnInclude are not working on Arrays, so 427 | // we need to compare each element separately. 428 | for (let i = 0; i < suggestions!.length; ++i) { 429 | if (requireExact) { 430 | assert.deepEqual(suggestions![i], expected[i]); 431 | } else { 432 | assert.deepOwnInclude(suggestions![i], expected[i]); 433 | } 434 | } 435 | } 436 | 437 | /** Asserts the Canvas URL. */ 438 | assertCanvasURL( 439 | expected: string | undefined | null, 440 | response?: protos.google.actions.sdk.v2.ISendInteractionResponse 441 | ) { 442 | const checkedResponse = response || this.latestResponse; 443 | expect( 444 | checkedResponse, 445 | `assertCanvasURL ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}` 446 | ).to.exist; 447 | const canvasURL = this.getCanvasURL(checkedResponse!); 448 | assert.equal(canvasURL, expected); 449 | } 450 | 451 | /** Asserts the Canvas Data. */ 452 | assertCanvasData( 453 | expected: any[], 454 | requireExact = false, 455 | response?: protos.google.actions.sdk.v2.ISendInteractionResponse 456 | ) { 457 | const checkedResponse = response || this.latestResponse; 458 | expect( 459 | checkedResponse, 460 | `assertCanvasData ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}` 461 | ).to.exist; 462 | const canvasData = this.getCanvasData(checkedResponse!); 463 | assert.exists(canvasData); 464 | assert.equal(canvasData!.length, expected.length); 465 | // Note: since deepEqual and deepOwnInclude are not working on Arrays, so 466 | // we need to compare each element separately. 467 | for (let i = 0; i < canvasData!.length; ++i) { 468 | if (requireExact) { 469 | assert.deepEqual(canvasData![i], expected[i]); 470 | } else { 471 | assert.deepOwnInclude(canvasData![i], expected[i]); 472 | } 473 | } 474 | } 475 | 476 | /** Asserts that the conversation ended, based on the response. */ 477 | assertConversationEnded( 478 | response?: protos.google.actions.sdk.v2.ISendInteractionResponse 479 | ) { 480 | const checkedResponse = response || this.latestResponse; 481 | expect( 482 | checkedResponse, 483 | `assertConversationEnded ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}` 484 | ).to.exist; 485 | assert.isTrue( 486 | this.getIsConversationEnded(checkedResponse!), 487 | 'Failed since Conversation is not completed as expected.' 488 | ); 489 | } 490 | 491 | /** Asserts that the conversation did not end, based on the response. */ 492 | assertConversationNotEnded( 493 | response?: protos.google.actions.sdk.v2.ISendInteractionResponse 494 | ) { 495 | const checkedResponse = response || this.latestResponse; 496 | expect( 497 | checkedResponse, 498 | `assertConversationNotEnded ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}` 499 | ).to.exist; 500 | assert.isFalse( 501 | this.getIsConversationEnded(checkedResponse!), 502 | 'Failed since Conversation has completed too early.' 503 | ); 504 | } 505 | 506 | /** 507 | * Asserts the session storage parameter value, in the given response. 508 | */ 509 | assertSessionParam( 510 | name: string, 511 | expected: any, 512 | response?: protos.google.actions.sdk.v2.ISendInteractionResponse 513 | ) { 514 | const checkedResponse = response || this.latestResponse; 515 | expect( 516 | checkedResponse, 517 | `assertSessionParam ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}` 518 | ).to.exist; 519 | const value = this.getSessionParam(name, checkedResponse!); 520 | assert.deepEqual( 521 | value, 522 | expected, 523 | 'Unexpected SessionParam variable ' + name 524 | ); 525 | } 526 | 527 | /** 528 | * Asserts the user storage parameter value, in the given response. 529 | */ 530 | assertUserParam( 531 | name: string, 532 | expected: any, 533 | response?: protos.google.actions.sdk.v2.ISendInteractionResponse 534 | ) { 535 | const checkedResponse = response || this.latestResponse; 536 | expect( 537 | checkedResponse, 538 | `assertUserParam ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}` 539 | ).to.exist; 540 | const value = this.getUserParam(name, checkedResponse!); 541 | assert.deepEqual(value, expected, 'Unexpected UserParam variable ' + name); 542 | } 543 | 544 | /** 545 | * Asserts the home storage parameter value, in the given response. 546 | */ 547 | assertHomeParam( 548 | name: string, 549 | expected: any, 550 | response?: protos.google.actions.sdk.v2.ISendInteractionResponse 551 | ) { 552 | const checkedResponse = response || this.latestResponse; 553 | expect( 554 | checkedResponse, 555 | `assertHomeParam ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}` 556 | ).to.exist; 557 | const value = this.getHomeParam(name, checkedResponse!); 558 | assert.deepEqual(value, expected, 'Unexpected HomeParam variable ' + name); 559 | } 560 | 561 | /** Asserts the Card response. */ 562 | assertCard( 563 | expected: protos.google.actions.sdk.v2.conversation.ICard, 564 | requireExact = false, 565 | response?: protos.google.actions.sdk.v2.ISendInteractionResponse 566 | ) { 567 | const checkedResponse = response || this.latestResponse; 568 | expect( 569 | checkedResponse, 570 | `assertCard ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}` 571 | ).to.exist; 572 | const card = this.getCard(checkedResponse!); 573 | if (requireExact) { 574 | assert.deepEqual(card, expected); 575 | } else { 576 | assert.deepOwnInclude(card, expected); 577 | } 578 | } 579 | 580 | /** Asserts the Media response. */ 581 | assertMedia( 582 | expected: protos.google.actions.sdk.v2.conversation.IMedia, 583 | requireExact = false, 584 | response?: protos.google.actions.sdk.v2.ISendInteractionResponse 585 | ) { 586 | const checkedResponse = response || this.latestResponse; 587 | expect( 588 | checkedResponse, 589 | `assertMedia ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}` 590 | ).to.exist; 591 | const media = this.getMedia(checkedResponse!); 592 | if (requireExact) { 593 | assert.deepEqual(media, expected); 594 | } else { 595 | assert.deepOwnInclude(media, expected); 596 | } 597 | } 598 | 599 | /** Asserts the Collection response. */ 600 | assertCollection( 601 | expected: protos.google.actions.sdk.v2.conversation.ICollection, 602 | requireExact = false, 603 | response?: protos.google.actions.sdk.v2.ISendInteractionResponse 604 | ) { 605 | const checkedResponse = response || this.latestResponse; 606 | expect( 607 | checkedResponse, 608 | `assertCollection ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}` 609 | ).to.exist; 610 | const collection = this.getCollection(checkedResponse!); 611 | if (requireExact) { 612 | assert.deepEqual(collection, expected); 613 | } else { 614 | assert.deepOwnInclude(collection, expected); 615 | } 616 | } 617 | 618 | /** Asserts the Image response. */ 619 | assertImage( 620 | expected: protos.google.actions.sdk.v2.conversation.IImage, 621 | requireExact = false, 622 | response?: protos.google.actions.sdk.v2.ISendInteractionResponse 623 | ) { 624 | const checkedResponse = response || this.latestResponse; 625 | expect( 626 | checkedResponse, 627 | `assertImage ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}` 628 | ).to.exist; 629 | const image = this.getImage(checkedResponse!); 630 | if (requireExact) { 631 | assert.deepEqual(image, expected); 632 | } else { 633 | assert.deepOwnInclude(image, expected); 634 | } 635 | } 636 | 637 | /** Asserts the Table response. */ 638 | assertTable( 639 | expected: protos.google.actions.sdk.v2.conversation.ITable, 640 | requireExact = false, 641 | response?: protos.google.actions.sdk.v2.ISendInteractionResponse 642 | ) { 643 | const checkedResponse = response || this.latestResponse; 644 | expect( 645 | checkedResponse, 646 | `assertTable ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}` 647 | ).to.exist; 648 | const table = this.getTable(checkedResponse!); 649 | if (requireExact) { 650 | assert.deepEqual(table, expected); 651 | } else { 652 | assert.deepOwnInclude(table, expected); 653 | } 654 | } 655 | 656 | /** Asserts the List response. */ 657 | assertList( 658 | expected: protos.google.actions.sdk.v2.conversation.IList, 659 | requireExact = false, 660 | response?: protos.google.actions.sdk.v2.ISendInteractionResponse 661 | ) { 662 | const checkedResponse = response || this.latestResponse; 663 | expect( 664 | checkedResponse, 665 | `assertList ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}` 666 | ).to.exist; 667 | const list = this.getList(checkedResponse!); 668 | if (requireExact) { 669 | assert.deepEqual(list, expected); 670 | } else { 671 | assert.deepOwnInclude(list, expected); 672 | } 673 | } 674 | 675 | /** 676 | * Asserts the expected intents for the checked query using the matchIntents 677 | * API call. 678 | */ 679 | async assertTopMatchedIntent( 680 | query: string, 681 | expectedIntent: string, 682 | requiredPlace = 1, 683 | queryLanguage: string 684 | ) { 685 | const matchedIntents = await this.getMatchIntentsList(query, queryLanguage); 686 | if (!matchedIntents) { 687 | this.throwError(`Query ${query} did not match to any intent.`); 688 | } 689 | if ( 690 | !matchedIntents || 691 | !matchedIntents!.slice(0, requiredPlace - 1).includes(expectedIntent) 692 | ) { 693 | this.throwError( 694 | `Query ${query} expected matched intent ${expectedIntent} is not part of the top ${requiredPlace} matched intents: ${JSON.stringify( 695 | matchedIntents 696 | )}` 697 | ); 698 | } 699 | } 700 | 701 | /** 702 | * Asserts that all queries in YAML file matches the expected top matched 703 | * intent, checked by using the matchIntents API call. 704 | * Will fail if any of the queries did not match the expected intent. 705 | */ 706 | async assertMatchIntentsFromYamlFile( 707 | yamlFile: string, 708 | queriesLanguage?: string 709 | ) { 710 | const fileContents = fs.readFileSync(yamlFile, 'utf8'); 711 | const yamlData = yaml.safeLoad(fileContents) as MatchIntentsTestSuite; 712 | expect(yamlData, `failed to read file ${yamlFile}`).to.exist; 713 | expect(yamlData!['testCases'], `Missing 'testCases' from ${yamlFile}`).to 714 | .exist; 715 | const failedQueries = []; 716 | for (const testCase of yamlData!.testCases!) { 717 | if (!testCase!['query']) { 718 | throw new Error('YAML file test entry is missing "query" field.'); 719 | } 720 | if (!testCase!['expectedIntent']) { 721 | throw new Error( 722 | 'YAML file test entry is missing "expectedIntent" field.' 723 | ); 724 | } 725 | let language = yamlData!['defaultLanguage']; 726 | if (!language) { 727 | expect( 728 | queriesLanguage, 729 | 'Failed since assertMatchIntentsFromYamlFile is missing a language' 730 | ).to.exist; 731 | language = queriesLanguage; 732 | } 733 | const matchResponse = await this.getMatchIntents( 734 | testCase!.query!, 735 | language! 736 | ); 737 | const topMatchedIntentName = this.getTopMatchIntentFromMatchResponse( 738 | matchResponse 739 | ); 740 | if (topMatchedIntentName !== testCase!['expectedIntent']) { 741 | failedQueries.push({ 742 | query: testCase!['query'], 743 | actual: topMatchedIntentName, 744 | expected: testCase!['expectedIntent'], 745 | }); 746 | } 747 | } 748 | expect( 749 | failedQueries, 750 | `The following queries have failed: ${JSON.stringify(failedQueries)}` 751 | ).to.be.empty; 752 | } 753 | 754 | /** Gets the intents for the checked query using the matchIntents API call. */ 755 | async getMatchIntents( 756 | query: string, 757 | queryLanguage: string 758 | ): Promise { 759 | const locale = 760 | queryLanguage || 761 | this.getTestInteractionMergedDefaults().deviceProperties!.locale!; 762 | return this.actionsApiHelper.matchIntents({locale, query}); 763 | } 764 | 765 | /** Gets the matched intents' names using the matchIntents API call. */ 766 | async getMatchIntentsList( 767 | query: string, 768 | queryLanguage: string 769 | ): Promise { 770 | const responseMatchIntents = await this.getMatchIntents( 771 | query, 772 | queryLanguage 773 | ); 774 | expect( 775 | responseMatchIntents['matchedIntents'], 776 | 'Failed to get matchedIntents section in from getMatchIntents response.' 777 | ).to.exist; 778 | return responseMatchIntents!.matchedIntents!.map(intent => { 779 | return intent.name!; 780 | }); 781 | } 782 | 783 | // --------------- Getters: 784 | /** Gets the latest turn full response. */ 785 | getLatestResponse(): protos.google.actions.sdk.v2.ISendInteractionResponse | null { 786 | return this.latestResponse; 787 | } 788 | 789 | /** 790 | * Gets the response Speech (concatenation of the first_simple and last_simple 791 | * Speech) 792 | */ 793 | getSpeech( 794 | response?: protos.google.actions.sdk.v2.ISendInteractionResponse 795 | ): string | undefined | null { 796 | const checkedResponse = response || this.latestResponse; 797 | expect( 798 | checkedResponse, 799 | `getSpeech ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}` 800 | ).to.exist; 801 | if ('speech' in checkedResponse!.output!) { 802 | return checkedResponse!.output!.speech!.join(''); 803 | } 804 | return this.getText(checkedResponse!); 805 | } 806 | 807 | /** 808 | * Gets the response Text (concatenation of the first_simple and last_simple 809 | * Text) 810 | */ 811 | getText( 812 | response?: protos.google.actions.sdk.v2.ISendInteractionResponse 813 | ): string | undefined | null { 814 | const checkedResponse = response || this.latestResponse; 815 | expect( 816 | checkedResponse, 817 | `getText ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}` 818 | ).to.exist; 819 | return checkedResponse!.output!['text']; 820 | } 821 | 822 | /** Gets the intent, from the response. */ 823 | getIntent( 824 | response?: protos.google.actions.sdk.v2.ISendInteractionResponse 825 | ): string { 826 | const checkedResponse = response || this.latestResponse; 827 | expect( 828 | checkedResponse, 829 | `getIntent ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}` 830 | ).to.exist; 831 | let intentName: string | null = null; 832 | if ('actionsBuilderEvents' in checkedResponse!.diagnostics!) { 833 | for (const actionsBuilderEvent of checkedResponse!.diagnostics! 834 | .actionsBuilderEvents!) { 835 | if ( 836 | actionsBuilderEvent['intentMatch'] && 837 | actionsBuilderEvent['intentMatch']['intentId'] 838 | ) { 839 | intentName = actionsBuilderEvent['intentMatch']['intentId']; 840 | } 841 | } 842 | } 843 | expect( 844 | intentName, 845 | `Unexpected issue: Failed to find intent name in the response ${JSON.stringify( 846 | checkedResponse 847 | )}` 848 | ).to.exist; 849 | return intentName!; 850 | } 851 | 852 | /** Gets the current intent parameter value, from the response. */ 853 | getIntentParameter( 854 | parameterName: string, 855 | response?: protos.google.actions.sdk.v2.ISendInteractionResponse 856 | ): any | null { 857 | const checkedResponse = response || this.latestResponse; 858 | expect( 859 | checkedResponse, 860 | `getIntentParameter ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}` 861 | ).to.exist; 862 | let intentMatch: protos.google.actions.sdk.v2.IIntentMatch | null = null; 863 | if ('actionsBuilderEvents' in checkedResponse!.diagnostics!) { 864 | for (const actionsBuilderEvent of checkedResponse!.diagnostics! 865 | .actionsBuilderEvents!) { 866 | if (actionsBuilderEvent['intentMatch']) { 867 | intentMatch = actionsBuilderEvent['intentMatch']; 868 | } 869 | } 870 | } 871 | if ( 872 | intentMatch && 873 | intentMatch!.intentParameters && 874 | parameterName in intentMatch!.intentParameters! && 875 | 'resolved' in intentMatch!.intentParameters[parameterName]! 876 | ) { 877 | const resolvedField = intentMatch!.intentParameters[parameterName]! 878 | .resolved!; 879 | // Now obtain the canonical value 880 | return getValueFromField(resolvedField); 881 | } 882 | return null; 883 | } 884 | 885 | /** Gets the last scene, from the response. */ 886 | getScene( 887 | response?: protos.google.actions.sdk.v2.ISendInteractionResponse 888 | ): string { 889 | const checkedResponse = response || this.latestResponse; 890 | expect( 891 | checkedResponse, 892 | `getScene ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}` 893 | ).to.exist; 894 | return ( 895 | this.getExecutionState(checkedResponse!)?.currentSceneId || 896 | constants.UNCHANGED_SCENE 897 | ); 898 | } 899 | 900 | /** Gets the Prompt. */ 901 | getPrompt( 902 | response?: protos.google.actions.sdk.v2.ISendInteractionResponse 903 | ): protos.google.actions.sdk.v2.conversation.IPrompt | undefined | null { 904 | const checkedResponse = response || this.latestResponse; 905 | expect( 906 | checkedResponse, 907 | `getContent ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}` 908 | ).to.exist; 909 | return checkedResponse!.output?.actionsBuilderPrompt; 910 | } 911 | 912 | /** 913 | * Gets the Canvas Data. 914 | */ 915 | getSuggestions( 916 | response?: protos.google.actions.sdk.v2.ISendInteractionResponse 917 | ): 918 | | protos.google.actions.sdk.v2.conversation.ISuggestion[] 919 | | undefined 920 | | null { 921 | const checkedResponse = response || this.latestResponse; 922 | expect( 923 | checkedResponse, 924 | `getSuggestions ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}` 925 | ).to.exist; 926 | return this.getPrompt(response)?.suggestions; 927 | } 928 | 929 | /** Gets the Prompt Content, if exists. */ 930 | getContent( 931 | response?: protos.google.actions.sdk.v2.ISendInteractionResponse 932 | ): protos.google.actions.sdk.v2.conversation.IContent | undefined | null { 933 | return this.getPrompt(response)?.content; 934 | } 935 | 936 | /** Gets the Card response, if exists. */ 937 | getCard( 938 | response?: protos.google.actions.sdk.v2.ISendInteractionResponse 939 | ): protos.google.actions.sdk.v2.conversation.ICard | undefined | null { 940 | const content = this.getContent(response); 941 | return content?.card; 942 | } 943 | 944 | /** Gets the Image response, if exists. */ 945 | getImage( 946 | response?: protos.google.actions.sdk.v2.ISendInteractionResponse 947 | ): protos.google.actions.sdk.v2.conversation.IImage | undefined | null { 948 | const content = this.getContent(response); 949 | return content?.image; 950 | } 951 | 952 | /** Gets the Table response, if exists. */ 953 | getTable( 954 | response?: protos.google.actions.sdk.v2.ISendInteractionResponse 955 | ): protos.google.actions.sdk.v2.conversation.ITable | undefined | null { 956 | const content = this.getContent(response); 957 | return content?.table; 958 | } 959 | 960 | /** Gets the Collection response, if exists. */ 961 | getCollection( 962 | response?: protos.google.actions.sdk.v2.ISendInteractionResponse 963 | ): protos.google.actions.sdk.v2.conversation.ICollection | undefined | null { 964 | const content = this.getContent(response); 965 | return content?.collection; 966 | } 967 | 968 | /** Gets the List response, if exists. */ 969 | getList( 970 | response?: protos.google.actions.sdk.v2.ISendInteractionResponse 971 | ): protos.google.actions.sdk.v2.conversation.IList | undefined | null { 972 | const content = this.getContent(response); 973 | return content?.list; 974 | } 975 | 976 | /** Gets the Media response, if exists. */ 977 | getMedia( 978 | response?: protos.google.actions.sdk.v2.ISendInteractionResponse 979 | ): protos.google.actions.sdk.v2.conversation.IMedia | undefined | null { 980 | const content = this.getContent(response); 981 | return content?.media; 982 | } 983 | 984 | /** Returns whether the conversation ended, based on the response. */ 985 | getIsConversationEnded( 986 | response?: protos.google.actions.sdk.v2.ISendInteractionResponse 987 | ): boolean { 988 | const checkedResponse = response || this.latestResponse; 989 | expect( 990 | checkedResponse, 991 | `getIsConversationEnded ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}` 992 | ).to.exist; 993 | if (!('actionsBuilderEvents' in checkedResponse!.diagnostics!)) { 994 | return true; 995 | } 996 | const actionsBuilderEvent = this.getLatestActionsBuilderEvent( 997 | checkedResponse! 998 | ); 999 | return 'endConversation' in actionsBuilderEvent!; 1000 | } 1001 | 1002 | /** 1003 | * Returns the value of the session param from the response. 1004 | */ 1005 | getSessionParam( 1006 | name: string, 1007 | response?: protos.google.actions.sdk.v2.ISendInteractionResponse 1008 | ): any { 1009 | const checkedResponse = response || this.latestResponse; 1010 | expect( 1011 | checkedResponse, 1012 | `getSessionParam ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}` 1013 | ).to.exist; 1014 | let value = null; 1015 | const executionState = this.getExecutionState(checkedResponse!); 1016 | if ( 1017 | executionState && 1018 | 'sessionStorage' in executionState && 1019 | name in executionState.sessionStorage!.fields! 1020 | ) { 1021 | const resolvedField = executionState.sessionStorage!.fields![name]; 1022 | // Now obtain the canonical value 1023 | value = getValueFromField(resolvedField); 1024 | } 1025 | return value; 1026 | } 1027 | 1028 | /** 1029 | * Returns the value of the user param from the response. 1030 | */ 1031 | getUserParam( 1032 | name: string, 1033 | response?: protos.google.actions.sdk.v2.ISendInteractionResponse 1034 | ): any { 1035 | const checkedResponse = response || this.latestResponse; 1036 | expect( 1037 | checkedResponse, 1038 | `getUserParam ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}` 1039 | ).to.exist; 1040 | let value = null; 1041 | const executionState = this.getExecutionState(checkedResponse!); 1042 | if ( 1043 | executionState && 1044 | 'userStorage' in executionState && 1045 | name in executionState.userStorage!.fields! 1046 | ) { 1047 | const resolvedField = executionState.userStorage!.fields![name]; 1048 | // Now obtain the canonical value 1049 | value = getValueFromField(resolvedField); 1050 | } 1051 | return value; 1052 | } 1053 | 1054 | /** 1055 | * Returns the value of the home (household) storage param from the response. 1056 | */ 1057 | getHomeParam( 1058 | name: string, 1059 | response?: protos.google.actions.sdk.v2.ISendInteractionResponse 1060 | ): any { 1061 | const checkedResponse = response || this.latestResponse; 1062 | expect( 1063 | checkedResponse, 1064 | `getHomeParam ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}` 1065 | ).to.exist; 1066 | const executionState = this.getExecutionState(checkedResponse!); 1067 | let value = null; 1068 | if ( 1069 | executionState && 1070 | 'householdStorage' in executionState && 1071 | name in executionState.householdStorage!.fields! 1072 | ) { 1073 | const resolvedField = executionState.householdStorage!.fields![name]; 1074 | // Now obtain the canonical value 1075 | value = getValueFromField(resolvedField); 1076 | } 1077 | return value; 1078 | } 1079 | 1080 | /** 1081 | * Gets the Canvas URL from the response. 1082 | */ 1083 | getCanvasURL( 1084 | response?: protos.google.actions.sdk.v2.ISendInteractionResponse 1085 | ): string | undefined | null { 1086 | const checkedResponse = response || this.latestResponse; 1087 | expect( 1088 | checkedResponse, 1089 | `getCanvasURL ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}` 1090 | ).to.exist; 1091 | return checkedResponse!.output!.canvas?.url; 1092 | } 1093 | 1094 | /** 1095 | * Gets the Canvas Data. 1096 | */ 1097 | getCanvasData( 1098 | response?: protos.google.actions.sdk.v2.ISendInteractionResponse 1099 | ): any[] | undefined | null { 1100 | const checkedResponse = response || this.latestResponse; 1101 | expect( 1102 | checkedResponse, 1103 | `getCanvasData ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}` 1104 | ).to.exist; 1105 | return checkedResponse!.output!.canvas?.data; 1106 | } 1107 | 1108 | /** Gets the execution state. */ 1109 | private getExecutionState( 1110 | response?: protos.google.actions.sdk.v2.ISendInteractionResponse 1111 | ): protos.google.actions.sdk.v2.IExecutionState | undefined | null { 1112 | const checkedResponse = response || this.latestResponse; 1113 | expect( 1114 | checkedResponse, 1115 | `getExecutionState ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}` 1116 | ).to.exist; 1117 | return this.getLatestActionsBuilderEvent(checkedResponse!)?.executionState; 1118 | } 1119 | 1120 | /** Gets the latest ActionsBuilderEvent. */ 1121 | private getLatestActionsBuilderEvent( 1122 | response?: protos.google.actions.sdk.v2.ISendInteractionResponse 1123 | ): protos.google.actions.sdk.v2.IExecutionEvent | undefined { 1124 | const checkedResponse = response || this.latestResponse; 1125 | expect( 1126 | checkedResponse, 1127 | `getActionsBuilderLatestEvent ${CANNOT_BE_CALLED_BEFORE_FIRST_QUERY_MESSAGE}` 1128 | ).to.exist; 1129 | return checkedResponse!.diagnostics?.actionsBuilderEvents?.slice(-1)[0]; 1130 | } 1131 | 1132 | /** Returns the i18n value of the key. */ 1133 | private i18n(name: string, params?: i18n.Replacements): string { 1134 | if (params) { 1135 | return i18n.__(name, params); 1136 | } 1137 | return i18n.__(name); 1138 | } 1139 | 1140 | /** Updates the current locale for the i18n util functions. */ 1141 | private updateCurrentLocale(locale: string) { 1142 | if ( 1143 | constants.SUPPORTED_LOCALES.concat( 1144 | Object.keys(constants.FALLBACK_LOCALES) 1145 | ).indexOf(locale) === -1 1146 | ) { 1147 | this.throwError( 1148 | `The provided locale '${locale}' is not a supported 'Actions On Google' locale.` 1149 | ); 1150 | return; 1151 | } 1152 | i18n.setLocale(locale); 1153 | } 1154 | 1155 | /** 1156 | * Asserts the value matched the expected string or array of string. 1157 | */ 1158 | private assertValueCommon( 1159 | value: string, 1160 | expected: string | string[], 1161 | checkName: string, 1162 | args: AssertValueArgs = {} 1163 | ) { 1164 | const isExact = 'isExact' in args ? args.isExact : false; 1165 | const isRegexp = 'isRegexp' in args ? args.isRegexp : false; 1166 | const expectedList = Array.isArray(expected) ? expected : [expected]; 1167 | let isMatch = false; 1168 | for (const expectedItem of expectedList) { 1169 | if (isRegexp) { 1170 | let itemRegexpMatch: RegExpMatchArray | null; 1171 | if (isExact) { 1172 | itemRegexpMatch = value.match('^' + expectedItem + '$'); 1173 | } else { 1174 | itemRegexpMatch = value.match(expectedItem); 1175 | } 1176 | if (itemRegexpMatch) { 1177 | isMatch = true; 1178 | } 1179 | } else { 1180 | let itemMatch: boolean; 1181 | if (isExact) { 1182 | itemMatch = value === expectedItem; 1183 | } else { 1184 | itemMatch = value.includes(expectedItem); 1185 | } 1186 | isMatch = isMatch || itemMatch; 1187 | } 1188 | } 1189 | if (isMatch) { 1190 | return; 1191 | } 1192 | let errorMessage = `Unexpected ${checkName}.\n --- Actual value is: ${JSON.stringify( 1193 | value 1194 | )}.\n --- Expected`; 1195 | if (isRegexp) { 1196 | errorMessage += ' to regexp match'; 1197 | } else { 1198 | errorMessage += ' to match'; 1199 | } 1200 | if (Array.isArray(expected)) { 1201 | errorMessage += ' one of'; 1202 | } 1203 | errorMessage += ':' + JSON.stringify(expected); 1204 | this.throwError(errorMessage); 1205 | } 1206 | 1207 | /** Throws an error with a given message. */ 1208 | throwError(errorStr: string) { 1209 | console.error(errorStr + '\n During user query: ' + this.lastUserQuery); 1210 | throw new Error(errorStr + '\n During user query: ' + this.lastUserQuery); 1211 | } 1212 | 1213 | /** Gets the text of 'stop' query in the requested locale. */ 1214 | private getStopQuery(): string { 1215 | return this.i18n('cancel'); 1216 | } 1217 | 1218 | /** Gets top matched intent name from the MatchedIntent response. */ 1219 | private getTopMatchIntentFromMatchResponse( 1220 | matchResponse: protos.google.actions.sdk.v2.IMatchIntentsResponse 1221 | ): string | null { 1222 | expect( 1223 | matchResponse['matchedIntents'], 1224 | 'Failed to get matchedIntents section in from getMatchIntents response.' 1225 | ).to.exist; 1226 | if (matchResponse.matchedIntents!.length > 0) { 1227 | const topMatch = matchResponse.matchedIntents![0]; 1228 | if ('name' in topMatch) { 1229 | return topMatch.name!; 1230 | } 1231 | } 1232 | return null; 1233 | } 1234 | 1235 | /** Validates that the response content is valid */ 1236 | private validateSendInteractionResponse( 1237 | response: protos.google.actions.sdk.v2.ISendInteractionResponse 1238 | ) { 1239 | expect(response, 'Unexpected API call issue: Response is empty').to.exist; 1240 | expect( 1241 | response!.diagnostics, 1242 | `Unexpected API call issue: Response 'diagnostics' is missing: ${JSON.stringify( 1243 | response 1244 | )}` 1245 | ).to.exist; 1246 | expect( 1247 | response!.output, 1248 | "Unexpected API call issue: Response 'diagnostics' is missing" 1249 | ).to.exist; 1250 | } 1251 | } 1252 | -------------------------------------------------------------------------------- /src/actions-api-helper.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | /* eslint-disable @typescript-eslint/no-explicit-any */ 18 | /** 19 | * @fileoverview Implementation of API calls to the Actions API. 20 | */ 21 | import {protos, v2} from '@assistant/actions'; 22 | 23 | const ACTIONS_API_PROD_ENDPOINT = 'actions.googleapis.com'; 24 | 25 | /** The ActionsApiHelper config. */ 26 | export interface ActionsApiHelperConfig { 27 | /** the tested project ID. */ 28 | projectId: string; 29 | /** optional custom actions API endpoint. */ 30 | actionsApiCustomEndpoint?: string; 31 | } 32 | 33 | /** 34 | * A class that implements API calls for Actions API. 35 | */ 36 | export class ActionsApiHelper { 37 | projectId: string; 38 | actionsSdkClient: v2.ActionsSdkClient; 39 | actionsTestingClient: v2.ActionsTestingClient; 40 | 41 | constructor({ 42 | projectId, 43 | actionsApiCustomEndpoint = ACTIONS_API_PROD_ENDPOINT, 44 | }: ActionsApiHelperConfig) { 45 | this.projectId = projectId; 46 | const options = { 47 | projectId, 48 | apiEndpoint: actionsApiCustomEndpoint, 49 | }; 50 | this.actionsSdkClient = new v2.ActionsSdkClient(options); 51 | this.actionsTestingClient = new v2.ActionsTestingClient(options); 52 | } 53 | 54 | /** Calls the 'sendInteraction' API method. */ 55 | async sendInteraction( 56 | interactionData: protos.google.actions.sdk.v2.ISendInteractionRequest 57 | ): Promise { 58 | try { 59 | interactionData.project = `projects/${this.projectId}`; 60 | const res = await this.actionsTestingClient.sendInteraction( 61 | interactionData 62 | ); 63 | return res[0] as protos.google.actions.sdk.v2.ISendInteractionResponse; 64 | } catch (err) { 65 | throw new Error(`sendInteraction API call failed: ${err}`); 66 | } 67 | } 68 | 69 | /** Calls the 'matchIntents' API method. */ 70 | async matchIntents( 71 | matchIntentsData: protos.google.actions.sdk.v2.IMatchIntentsRequest 72 | ): Promise { 73 | try { 74 | matchIntentsData.project = `projects/${this.projectId}`; 75 | const res = await this.actionsTestingClient.matchIntents( 76 | matchIntentsData 77 | ); 78 | return res[0] as protos.google.actions.sdk.v2.IMatchIntentsResponse; 79 | } catch (err) { 80 | throw new Error(`matchIntents API call failed: ${err}`); 81 | } 82 | } 83 | 84 | /** Calls the 'writePreview' API method from draft. */ 85 | async writePreviewFromDraft() { 86 | await this._writePreview({ 87 | parent: `projects/${this.projectId}`, 88 | previewSettings: {sandbox: {value: true}}, 89 | draft: {}, 90 | }); 91 | } 92 | 93 | /** Calls the 'writePreview' API method from submitted version number. */ 94 | async writePreviewFromVersion(versionNumber: number) { 95 | await this._writePreview({ 96 | parent: `projects/${this.projectId}`, 97 | previewSettings: {sandbox: {value: true}}, 98 | submittedVersion: { 99 | version: `projects/${this.projectId}/versions/${versionNumber}`, 100 | }, 101 | }); 102 | } 103 | 104 | /** Calls the 'writePreview' API method given a write preview request. */ 105 | private _writePreview( 106 | request: protos.google.actions.sdk.v2.IWritePreviewRequest 107 | ) { 108 | const [ 109 | responsePromise, 110 | responseCallback, 111 | ] = this._getStreamResponsePromise(); 112 | const writePreviewStream = this.actionsSdkClient.writePreview( 113 | responseCallback 114 | ); 115 | writePreviewStream.write(request); 116 | writePreviewStream.end(); 117 | return responsePromise; 118 | } 119 | 120 | /** Gets a resonse promise and callback for a stream request. */ 121 | private _getStreamResponsePromise(): [ 122 | Promise, 123 | (err: any, resp: any) => void 124 | ] { 125 | let writeSuccess: any, writeFailure: any; 126 | const responsePromise = new Promise((resolve, reject) => { 127 | writeSuccess = resolve; 128 | writeFailure = reject; 129 | }); 130 | const responseCallback = (err: any, resp: any) => { 131 | !err ? writeSuccess(resp) : writeFailure(err); 132 | }; 133 | return [responsePromise, responseCallback]; 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | /** 18 | * @fileoverview Used to store export constant values / default config values 19 | */ 20 | import {protos} from '@assistant/actions'; 21 | 22 | /** 23 | * List of supported action locales by the library, which does not require 24 | * fallback. 25 | */ 26 | export const SUPPORTED_LOCALES = [ 27 | 'en-US', 28 | 'fr-FR', 29 | 'ja-JP', 30 | 'de-DE', 31 | 'ko-KR', 32 | 'es-ES', 33 | 'pt-BR', 34 | 'it-IT', 35 | 'ru-RU', 36 | 'hi-IN', 37 | 'th-TH', 38 | 'id-ID', 39 | 'da-DK', 40 | 'no-NO', 41 | 'nl-NL', 42 | 'sv-SE', 43 | 'tr-TR', 44 | 'pl-PL', 45 | 'zh-HK', 46 | 'zh-TW', 47 | ]; 48 | 49 | /** Fallback locales mapping for i18n configuration. */ 50 | export const FALLBACK_LOCALES = { 51 | 'en-GB': 'en-US', 52 | 'en-AU': 'en-US', 53 | 'en-SG': 'en-US', 54 | 'en-CA': 'en-US', 55 | 'en-IN': 'en-US', 56 | 'en-BE': 'en-US', 57 | 'fr-CA': 'fr-FR', 58 | 'fr-BE': 'fr-FR', 59 | 'es-419': 'es-ES', 60 | 'nl-BE': 'nl-NL', 61 | 'de-AT': 'de-DE', 62 | 'de-CH': 'de-DE', 63 | 'de-BE': 'de-DE', 64 | }; 65 | 66 | /** The default library locale. */ 67 | export const DEFAULT_LOCALE = SUPPORTED_LOCALES[0]; 68 | /** The default library surface. */ 69 | export const DEFAULT_SURFACE = 'PHONE'; 70 | /** The default library user input type. */ 71 | export const DEFAULT_INPUT_TYPE = 'VOICE'; 72 | /** The default library longitude. */ 73 | export const DEFAULT_LOCATION_LONG = 37.422; 74 | /** The default library latitude . */ 75 | export const DEFAULT_LOCATION_LAT = -122.084; 76 | 77 | /** The default library timezone. */ 78 | export const DEFAULT_TIMEZONE = 'America/Los_Angeles'; 79 | 80 | /** The library's interaction defaults. */ 81 | export const DEFAULT_INTERACTION_SETTING: protos.google.actions.sdk.v2.ISendInteractionRequest = { 82 | input: { 83 | type: DEFAULT_INPUT_TYPE as keyof typeof protos.google.actions.sdk.v2.UserInput.InputType, 84 | }, 85 | deviceProperties: { 86 | locale: DEFAULT_LOCALE, 87 | surface: DEFAULT_SURFACE as keyof typeof protos.google.actions.sdk.v2.DeviceProperties.Surface, 88 | timeZone: DEFAULT_TIMEZONE, 89 | location: { 90 | coordinates: { 91 | latitude: DEFAULT_LOCATION_LAT, 92 | longitude: DEFAULT_LOCATION_LONG, 93 | }, 94 | }, 95 | }, 96 | }; 97 | 98 | /** Conversation token field name. */ 99 | export const TOKEN_FIELD_NAME = 'conversationToken'; 100 | 101 | /** 102 | * Returned scene name string, incase the scene did not change in the last 103 | * dialog turn. 104 | */ 105 | export const UNCHANGED_SCENE = '_UNCHANGED_'; 106 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | 18 | export * from './action-on-google-test-manager'; 19 | -------------------------------------------------------------------------------- /src/merge.ts: -------------------------------------------------------------------------------- 1 | /** @fileoverview Merge utils. */ 2 | 3 | /** 4 | * Merges target object with the base object recursively and returns newly 5 | * created object. The values of the target object have priority over the base 6 | * values. 7 | * 8 | * Functions, Map, Set, Arrays or any other 'non-plain' JSON objects are 9 | * copied by reference. Plain JSON objects not found in the 'partial' are also 10 | * copied by reference. 11 | */ 12 | export function getDeepMerge(base: T, target: T): T { 13 | return unprotectedDeepMerge(deepClone(base), target); 14 | } 15 | 16 | /** 17 | * Returns a clone of the given source object with all its fields recursively 18 | * cloned. 19 | */ 20 | export function deepClone(source: T): T { 21 | return unprotectedDeepMerge({} as T, source); 22 | } 23 | 24 | /** 25 | * Merges target object with the base object recursively and returns newly 26 | * created object. 27 | * Unlike getDeepMerge This merge does not protected copies of the 'base', 28 | * and is only for internal usage by the getDeepMerge. 29 | */ 30 | function unprotectedDeepMerge(base: T, target: T): T { 31 | if (!isPlainObject(base) || !isPlainObject(target)) { 32 | return target; 33 | } 34 | const result = {...base}; 35 | for (const key of Object.keys(target) as Array) { 36 | const baseValue = base[key]; 37 | const partialValue = target[key]; 38 | result[key] = getDeepMerge(baseValue, partialValue); 39 | } 40 | return result; 41 | } 42 | 43 | /** Checks if the object is a plain JSON object. */ 44 | function isPlainObject(obj: unknown): obj is object { 45 | return !!obj && typeof obj === 'object' && obj!.constructor === Object; 46 | } 47 | -------------------------------------------------------------------------------- /src/test/mock-response1.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2021 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the 'License'); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an 'AS IS' BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | /** 18 | * @fileoverview Type-safe mocked response for unit tests 19 | */ 20 | import {protos} from '@assistant/actions'; 21 | 22 | const mockedResponse: protos.google.actions.sdk.v2.SendInteractionResponse = { 23 | output: { 24 | speech: [ 25 | 'Welcome to Facts about Google!', 26 | ' What type of facts would you like to hear?', 27 | ], 28 | text: 29 | 'Welcome to Facts about Google! What type of facts would you like to hear?', 30 | }, 31 | diagnostics: { 32 | actionsBuilderEvents: [ 33 | { 34 | eventTime: { 35 | seconds: 1595450517, 36 | nanos: 652000000, 37 | }, 38 | executionState: { 39 | currentSceneId: 'actions.scene.START_CONVERSATION', 40 | }, 41 | status: {}, 42 | userInput: {}, 43 | }, 44 | { 45 | eventTime: { 46 | seconds: 1595450517, 47 | nanos: 652000000, 48 | }, 49 | executionState: { 50 | currentSceneId: 'actions.scene.START_CONVERSATION', 51 | sessionStorage: {}, 52 | promptQueue: [ 53 | { 54 | firstSimple: { 55 | speech: 'Welcome to Facts about Google! ', 56 | text: 'Welcome to Facts about Google! ', 57 | }, 58 | }, 59 | ], 60 | }, 61 | status: {}, 62 | intentMatch: { 63 | intentId: 'actions.intent.MAIN', 64 | intentParameters: { 65 | intentParamString: { 66 | resolved: { 67 | stringValue: 'value', 68 | }, 69 | }, 70 | intentParamInt: { 71 | resolved: { 72 | numberValue: 5, 73 | }, 74 | }, 75 | }, 76 | nextSceneId: 'Welcome', 77 | }, 78 | }, 79 | { 80 | eventTime: { 81 | seconds: 1595450517, 82 | nanos: 652000000, 83 | }, 84 | executionState: { 85 | currentSceneId: 'Welcome', 86 | sessionStorage: {}, 87 | userStorage: { 88 | fields: { 89 | key: { 90 | stringValue: 'verificationStatus', 91 | }, 92 | value: { 93 | stringValue: 'VERIFIED', 94 | }, 95 | }, 96 | }, 97 | promptQueue: [ 98 | { 99 | firstSimple: { 100 | speech: 'Welcome to Facts about Google! ', 101 | text: 'Welcome to Facts about Google! ', 102 | }, 103 | }, 104 | ], 105 | }, 106 | status: {}, 107 | onSceneEnter: {}, 108 | }, 109 | { 110 | eventTime: { 111 | seconds: 1595450517, 112 | nanos: 652000000, 113 | }, 114 | executionState: { 115 | currentSceneId: 'Welcome', 116 | sessionStorage: {}, 117 | slots: { 118 | status: 'COLLECTING', 119 | slots: { 120 | factCategory: { 121 | mode: 'REQUIRED', 122 | status: 'EMPTY', 123 | }, 124 | }, 125 | }, 126 | userStorage: { 127 | fields: { 128 | key: { 129 | stringValue: 'verificationStatus', 130 | }, 131 | value: { 132 | stringValue: 'VERIFIED', 133 | }, 134 | }, 135 | }, 136 | promptQueue: [ 137 | { 138 | firstSimple: { 139 | speech: 'Welcome to Facts about Google! ', 140 | text: 'Welcome to Facts about Google! ', 141 | }, 142 | }, 143 | ], 144 | }, 145 | status: {}, 146 | conditionsEvaluated: { 147 | failedConditions: [ 148 | { 149 | expression: "$scene.slots.status = 'FINAL'", 150 | handler: 'getFact', 151 | }, 152 | ], 153 | }, 154 | }, 155 | { 156 | eventTime: { 157 | seconds: 1595450517, 158 | nanos: 652000000, 159 | }, 160 | executionState: { 161 | currentSceneId: 'Welcome', 162 | sessionStorage: {}, 163 | slots: { 164 | status: 'COLLECTING', 165 | slots: { 166 | factCategory: { 167 | mode: 'REQUIRED', 168 | status: 'EMPTY', 169 | }, 170 | }, 171 | }, 172 | userStorage: { 173 | fields: { 174 | key: { 175 | stringValue: 'verificationStatus', 176 | }, 177 | value: { 178 | stringValue: 'VERIFIED', 179 | }, 180 | }, 181 | }, 182 | promptQueue: [ 183 | { 184 | firstSimple: { 185 | speech: 186 | 'Welcome to Facts about Google! What type of facts would you like to hear?', 187 | text: 188 | 'Welcome to Facts about Google! What type of facts would you like to hear?', 189 | }, 190 | suggestions: [ 191 | { 192 | title: 'Headquarters', 193 | }, 194 | { 195 | title: 'History', 196 | }, 197 | ], 198 | }, 199 | ], 200 | }, 201 | status: {}, 202 | slotRequested: { 203 | slot: 'factCategory', 204 | prompt: { 205 | firstSimple: { 206 | speech: 207 | 'Welcome to Facts about Google! What type of facts would you like to hear?', 208 | text: 209 | 'Welcome to Facts about Google! What type of facts would you like to hear?', 210 | }, 211 | suggestions: [ 212 | { 213 | title: 'Headquarters', 214 | }, 215 | { 216 | title: 'History', 217 | }, 218 | ], 219 | }, 220 | }, 221 | }, 222 | { 223 | eventTime: { 224 | seconds: 1595450517, 225 | nanos: 652000000, 226 | }, 227 | executionState: { 228 | currentSceneId: 'Welcome', 229 | sessionStorage: { 230 | fields: { 231 | categoryType: { 232 | stringValue: 'cats', 233 | }, 234 | factsNumber: { 235 | numberValue: 1, 236 | }, 237 | currentFactsData: { 238 | structValue: { 239 | fields: { 240 | title: { 241 | stringValue: 'Fact fake title', 242 | }, 243 | }, 244 | }, 245 | }, 246 | potentialFactCategories: { 247 | listValue: { 248 | values: [ 249 | { 250 | stringValue: 'cats', 251 | }, 252 | { 253 | stringValue: 'dogs', 254 | }, 255 | ], 256 | }, 257 | }, 258 | }, 259 | }, 260 | userStorage: { 261 | fields: { 262 | userCategoryType: { 263 | stringValue: 'cats', 264 | }, 265 | userFactsNumber: { 266 | numberValue: 1, 267 | }, 268 | }, 269 | }, 270 | householdStorage: { 271 | fields: { 272 | homeStorageParam: { 273 | stringValue: 'home param value', 274 | }, 275 | }, 276 | }, 277 | slots: { 278 | status: 'COLLECTING', 279 | slots: { 280 | factCategory: { 281 | mode: 'REQUIRED', 282 | status: 'EMPTY', 283 | }, 284 | }, 285 | }, 286 | promptQueue: [ 287 | { 288 | firstSimple: { 289 | speech: 290 | 'Welcome to Facts about Google! What type of facts would you like to hear?', 291 | text: 292 | 'Welcome to Facts about Google! What type of facts would you like to hear?', 293 | }, 294 | suggestions: [ 295 | { 296 | title: 'Headquarters', 297 | }, 298 | { 299 | title: 'History', 300 | }, 301 | ], 302 | }, 303 | ], 304 | }, 305 | status: {}, 306 | waitingUserInput: {}, 307 | }, 308 | ], 309 | }, 310 | conversationToken: 'EosDS2o5Wk0xTndRazFQYUZOMFZUSlpT', 311 | toJSON: () => { 312 | // no-op, for compat purposes only 313 | return {}; 314 | }, 315 | }; 316 | 317 | export default mockedResponse; 318 | -------------------------------------------------------------------------------- /src/test/test-data.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | /* eslint-disable @typescript-eslint/no-explicit-any */ 18 | /** 19 | * @fileoverview Test data used by the library unit tests. 20 | */ 21 | import {protos} from '@assistant/actions'; 22 | 23 | /** Example Suggestions. */ 24 | export const EXAMPLE_SUGGESTIONS: protos.google.actions.sdk.v2.conversation.ISuggestion[] = [ 25 | {title: 'Headquarters'}, 26 | {title: 'History'}, 27 | ]; 28 | 29 | /** Example Image. */ 30 | export const EXAMPLE_IMAGE: protos.google.actions.sdk.v2.conversation.IImage = { 31 | url: 'https://developers.google.com/assistant/assistant_96.png', 32 | alt: 'Google Assistant logo', 33 | }; 34 | 35 | /** Example Card. */ 36 | export const EXAMPLE_CARD: protos.google.actions.sdk.v2.conversation.ICard = { 37 | title: 'Card Title', 38 | subtitle: 'Card Subtitle', 39 | text: 'Card Content', 40 | image: EXAMPLE_IMAGE, 41 | }; 42 | 43 | /** Example List. */ 44 | export const EXAMPLE_LIST: protos.google.actions.sdk.v2.conversation.IList = { 45 | title: 'List title', 46 | subtitle: 'List subtitle', 47 | items: [{key: 'ITEM_1'}, {key: 'ITEM_2'}, {key: 'ITEM_3'}, {key: 'ITEM_4'}], 48 | }; 49 | 50 | /** Example Collection. */ 51 | export const EXAMPLE_COLLECTION: protos.google.actions.sdk.v2.conversation.ICollection = { 52 | title: 'Collection Title', 53 | subtitle: 'Collection subtitle', 54 | items: [{key: 'ITEM_1'}, {key: 'ITEM_2'}, {key: 'ITEM_3'}, {key: 'ITEM_4'}], 55 | }; 56 | 57 | /** Example Table. */ 58 | export const EXAMPLE_TABLE: protos.google.actions.sdk.v2.conversation.ITable = { 59 | title: 'Table Title', 60 | subtitle: 'Table Subtitle', 61 | image: EXAMPLE_IMAGE, 62 | columns: [{header: 'Column A'}, {header: 'Column B'}, {header: 'Column C'}], 63 | rows: [ 64 | {cells: [{text: 'A1'}, {text: 'B1'}, {text: 'C1'}]}, 65 | {cells: [{text: 'A2'}, {text: 'B2'}, {text: 'C2'}]}, 66 | {cells: [{text: 'A3'}, {text: 'B3'}, {text: 'C3'}]}, 67 | ], 68 | }; 69 | 70 | /** Example Media Card. */ 71 | export const EXAMPLE_MEDIA: protos.google.actions.sdk.v2.conversation.IMedia = { 72 | optionalMediaControls: [ 73 | protos.google.actions.sdk.v2.conversation.Media.OptionalMediaControls 74 | .PAUSED, 75 | protos.google.actions.sdk.v2.conversation.Media.OptionalMediaControls 76 | .STOPPED, 77 | ], 78 | mediaObjects: [ 79 | { 80 | name: 'Media name', 81 | description: 'Media description', 82 | url: 'https://actions.google.com/sounds/v1/cartoon/cartoon_boing.ogg', 83 | image: {large: EXAMPLE_IMAGE}, 84 | }, 85 | ], 86 | mediaType: 'AUDIO', 87 | }; 88 | 89 | /** Example Canvas Response. */ 90 | export const EXAMPLE_CANVAS: protos.google.actions.sdk.v2.conversation.ICanvas = { 91 | url: 'https://canvas.url', 92 | data: [ 93 | {elem1Key1: 'value', elem1Key2: 'value2'} as any, 94 | {elem2Key1: 'value2'} as any, 95 | ], 96 | suppressMic: true, 97 | }; 98 | -------------------------------------------------------------------------------- /src/test/test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | * 16 | */ 17 | /** 18 | * @fileoverview Unit tests for the Library. 19 | */ 20 | /* eslint-disable node/no-unpublished-import */ 21 | /* eslint-disable prefer-arrow-callback */ 22 | 23 | import 'mocha'; 24 | 25 | import {protos} from '@assistant/actions'; 26 | import {assert, expect} from 'chai'; 27 | 28 | import * as constants from '../constants'; 29 | import {ActionsOnGoogleTestManager} from '../index'; 30 | import {deepClone} from '../merge'; 31 | 32 | import mockResponse1 from './mock-response1'; 33 | import { 34 | EXAMPLE_CANVAS, 35 | EXAMPLE_CARD, 36 | EXAMPLE_COLLECTION, 37 | EXAMPLE_IMAGE, 38 | EXAMPLE_LIST, 39 | EXAMPLE_MEDIA, 40 | EXAMPLE_SUGGESTIONS, 41 | EXAMPLE_TABLE, 42 | } from './test-data'; 43 | 44 | describe('ActionsOnGoogleTestManager', function () { 45 | let test: ActionsOnGoogleTestManager; 46 | 47 | /** Updates the response's content part. */ 48 | function updatedResponseContent( 49 | baseResponse: protos.google.actions.sdk.v2.ISendInteractionResponse, 50 | updatedContent: protos.google.actions.sdk.v2.conversation.IContent 51 | ) { 52 | baseResponse!.output!.actionsBuilderPrompt = {content: updatedContent}; 53 | } 54 | 55 | /** Initializes the latest turn response to the mock response. */ 56 | function initLatestResponse() { 57 | test.latestResponse = deepClone( 58 | mockResponse1 as protos.google.actions.sdk.v2.ISendInteractionResponse 59 | ); 60 | } 61 | 62 | before('before all', function () { 63 | test = new ActionsOnGoogleTestManager({projectId: 'FAKE_PROJECT_ID'}); 64 | }); 65 | 66 | it('should set default locale and surface', async function () { 67 | const newLocale = 'en-US'; 68 | const newSurface = 'PHONE'; 69 | assert.deepEqual( 70 | test.getTestInteractionMergedDefaults(), 71 | constants.DEFAULT_INTERACTION_SETTING 72 | ); 73 | const expectedDeviceProperties = 74 | constants.DEFAULT_INTERACTION_SETTING.deviceProperties; 75 | assert.deepOwnInclude(test.getTestInteractionMergedDefaults(), { 76 | deviceProperties: expectedDeviceProperties, 77 | }); 78 | test.setSuiteLocale(newLocale); 79 | expectedDeviceProperties!.locale = newLocale; 80 | assert.deepOwnInclude(test.getTestInteractionMergedDefaults(), { 81 | deviceProperties: expectedDeviceProperties, 82 | }); 83 | expectedDeviceProperties!.surface = newSurface; 84 | test.setSuiteSurface(newSurface); 85 | assert.deepOwnInclude(test.getTestInteractionMergedDefaults(), { 86 | deviceProperties: expectedDeviceProperties, 87 | }); 88 | }); 89 | 90 | it('should get and assert speech from latest response', async function () { 91 | initLatestResponse(); 92 | const expectedSpeech = 93 | 'Welcome to Facts about Google! What type of facts would you like to hear?'; 94 | assert.equal(test.getSpeech(), expectedSpeech); 95 | expect(() => test.assertSpeech(expectedSpeech)).not.to.throw(); 96 | expect(() => 97 | test.assertSpeech(expectedSpeech, { 98 | isExact: true, 99 | }) 100 | ).not.to.throw(); 101 | expect(() => test.assertSpeech('Welcome')).not.to.throw(); 102 | expect(() => 103 | test.assertSpeech(['Welcome to .* about Google!'], { 104 | isRegexp: true, 105 | }) 106 | ).not.to.throw(); 107 | expect(() => 108 | test.assertSpeech(['no match', 'Welcome to Facts about Google!']) 109 | ).not.to.throw(); 110 | expect(() => test.assertSpeech('bad')).to.throw(); 111 | expect(() => test.assertSpeech(['bad', 'bad2'])).to.throw(); 112 | expect(() => 113 | test.assertSpeech(['bad .* about Google!', 'bad'], { 114 | isRegexp: true, 115 | }) 116 | ).to.throw(); 117 | expect(() => 118 | test.assertSpeech(['Welcome to Facts about Google!'], { 119 | isExact: true, 120 | }) 121 | ).to.throw(); 122 | }); 123 | 124 | it('should get and assert text from latest response', async function () { 125 | initLatestResponse(); 126 | const expectedText = 127 | 'Welcome to Facts about Google! What type of facts would you like to hear?'; 128 | assert.equal(test.getText(), expectedText); 129 | expect(() => test.assertText(expectedText)).not.to.throw(); 130 | expect(() => test.assertText(expectedText, {isExact: true})).not.to.throw(); 131 | expect(() => test.assertText('Welcome')).not.to.throw(); 132 | expect(() => 133 | test.assertText(['Welcome to .* about Google!'], { 134 | isRegexp: true, 135 | }) 136 | ).not.to.throw(); 137 | expect(() => 138 | test.assertText(['no match', 'Welcome to Facts about Google!']) 139 | ).not.to.throw(); 140 | expect(() => test.assertText('bad')).to.throw(); 141 | expect(() => test.assertText(['bad', 'bad2'])).to.throw(); 142 | expect(() => 143 | test.assertText(['bad .* about Google!', 'bad'], { 144 | isRegexp: true, 145 | }) 146 | ).to.throw(); 147 | expect(() => 148 | test.assertText(['Welcome to Facts about Google!'], { 149 | isExact: true, 150 | }) 151 | ).to.throw(); 152 | }); 153 | 154 | it('should get and assert matched intent from latest response', async function () { 155 | initLatestResponse(); 156 | assert.equal(test.getIntent(), 'actions.intent.MAIN'); 157 | expect(() => test.assertIntent('actions.intent.MAIN')).not.to.throw(); 158 | expect(() => test.assertIntent('BAD_INTENT')).to.throw(); 159 | }); 160 | 161 | it('should get and assert intent parameter from latest response', async function () { 162 | initLatestResponse(); 163 | assert.equal(test.getIntentParameter('intentParamString'), 'value'); 164 | assert.equal(test.getIntentParameter('intentParamInt'), 5); 165 | assert.equal(test.getIntentParameter('badName'), null); 166 | expect(() => 167 | test.assertIntentParameter('intentParamString', 'value') 168 | ).not.to.throw(); 169 | expect(() => 170 | test.assertIntentParameter('intentParamInt', 5) 171 | ).not.to.throw(); 172 | expect(() => 173 | test.assertIntentParameter('BadIntentParamName', 'blabla') 174 | ).to.throw(); 175 | expect(() => 176 | test.assertIntentParameter('intentParamString', 'blabla') 177 | ).to.throw(); 178 | expect(() => test.assertIntentParameter('intentParamInt', 101)).to.throw(); 179 | expect(() => 180 | test.assertIntentParameter('intentParamInt', 'blabla') 181 | ).to.throw(); 182 | }); 183 | 184 | it('should get and assert current scene from latest response', async function () { 185 | initLatestResponse(); 186 | assert.equal(test.getScene(), 'Welcome'); 187 | expect(() => test.assertScene('Welcome')).not.to.throw(); 188 | expect(() => test.assertScene('BAD_SCENE_NAME')).to.throw(); 189 | }); 190 | 191 | it('should get and assert session parameters from latest response', async function () { 192 | initLatestResponse(); 193 | assert.equal(test.getSessionParam('categoryType'), 'cats'); 194 | expect(() => 195 | test.assertSessionParam('categoryType', 'cats') 196 | ).not.to.throw(); 197 | expect(() => test.assertSessionParam('factsNumber', 1)).not.to.throw(); 198 | expect(() => 199 | test.assertSessionParam('currentFactsData', { 200 | title: 'Fact fake title', 201 | }) 202 | ).not.to.throw(); 203 | expect(() => test.assertSessionParam('categoryType', 'bad')).to.throw(); 204 | expect(() => test.assertSessionParam('factsNumber', 10)).to.throw(); 205 | expect(() => test.assertSessionParam('missingKey', 'bad')).to.throw(); 206 | expect(() => 207 | test.assertSessionParam('currentFactsData', { 208 | title: 'Wrong title', 209 | }) 210 | ).to.throw(); 211 | expect(() => 212 | test.assertSessionParam('currentFactsData', { 213 | 'bad key': 'Wrong title', 214 | }) 215 | ).to.throw(); 216 | }); 217 | 218 | it('should get and assert user parameters from latest response', async function () { 219 | initLatestResponse(); 220 | assert.equal(test.getUserParam('userCategoryType'), 'cats'); 221 | expect(() => 222 | test.assertUserParam('userCategoryType', 'cats') 223 | ).not.to.throw(); 224 | expect(() => test.assertUserParam('userFactsNumber', 1)).not.to.throw(); 225 | expect(() => test.assertUserParam('userCategoryType', 'bad')).to.throw(); 226 | expect(() => test.assertUserParam('userFactsNumber', 10)).to.throw(); 227 | expect(() => test.assertUserParam('missingKey', 'bad')).to.throw(); 228 | }); 229 | 230 | it('should get home parameters from latest response', async () => { 231 | initLatestResponse(); 232 | assert.equal(test.getHomeParam('homeStorageParam'), 'home param value'); 233 | expect(() => 234 | test.assertHomeParam('homeStorageParam', 'home param value') 235 | ).not.to.throw(); 236 | expect(() => test.assertHomeParam('homeStorageParam', 'bad')).to.throw(); 237 | expect(() => test.assertHomeParam('missingKey', 'bad')).to.throw(); 238 | }); 239 | 240 | it('should get and assert content from latest response', async () => { 241 | initLatestResponse(); 242 | const content = {card: EXAMPLE_CARD}; 243 | updatedResponseContent(test.latestResponse!, content); 244 | assert.equal(test.getContent(), content); 245 | }); 246 | 247 | it('should get and assert prompt from latest response', async () => { 248 | initLatestResponse(); 249 | const prompt = { 250 | firstSimple: { 251 | speech: 252 | 'Welcome to Facts about Google! What type of facts would you like to hear?', 253 | text: 254 | 'Welcome to Facts about Google! What type of facts would you like to hear?', 255 | }, 256 | suggestions: [{title: 'Headquarters'}, {title: 'History'}], 257 | }; 258 | test.latestResponse!.output!.actionsBuilderPrompt = prompt; 259 | assert.equal(test.getPrompt(), prompt); 260 | expect(() => 261 | test.assertPrompt({ 262 | firstSimple: { 263 | speech: 264 | 'Welcome to Facts about Google! What type of facts would you like to hear?', 265 | text: 266 | 'Welcome to Facts about Google! What type of facts would you like to hear?', 267 | }, 268 | }) 269 | ).not.to.throw(); 270 | expect(() => test.assertPrompt(prompt)).not.to.throw(); 271 | expect(() => test.assertPrompt(prompt, true)).not.to.throw(); 272 | }); 273 | 274 | it('should get and assert suggestions from latest response', async () => { 275 | initLatestResponse(); 276 | assert.deepEqual(test.getSuggestions(), EXAMPLE_SUGGESTIONS); 277 | expect(() => 278 | test.assertSuggestions(EXAMPLE_SUGGESTIONS, true) 279 | ).not.to.throw(); 280 | expect(() => test.assertSuggestions(EXAMPLE_SUGGESTIONS)).not.to.throw(); 281 | expect(() => test.assertSuggestions([{title: 'Headquarters'}])).to.throw(); 282 | }); 283 | 284 | it('should get and assert card from latest response', async () => { 285 | initLatestResponse(); 286 | updatedResponseContent(test.latestResponse!, {card: EXAMPLE_CARD}); 287 | assert.equal(test.getCard(), EXAMPLE_CARD); 288 | expect(() => test.assertCard(EXAMPLE_CARD)).not.to.throw(); 289 | expect(() => test.assertCard(EXAMPLE_CARD, true)).not.to.throw(); 290 | expect(() => test.assertCard({title: 'Card Title'})).not.to.throw(); 291 | expect(() => test.assertCard({image: EXAMPLE_IMAGE})).not.to.throw(); 292 | 293 | expect(() => test.assertCard({title: 'Card Title'}, true)).to.throw(); 294 | expect(() => test.assertCard({title: 'Bad Card Title'})).to.throw(); 295 | }); 296 | 297 | it('should get and assert image from latest response', async () => { 298 | initLatestResponse(); 299 | updatedResponseContent(test.latestResponse!, {image: EXAMPLE_IMAGE}); 300 | assert.equal(test.getImage(), EXAMPLE_IMAGE); 301 | expect(() => test.assertImage(EXAMPLE_IMAGE!)).not.to.throw(); 302 | expect(() => test.assertImage(EXAMPLE_IMAGE!, true)).not.to.throw(); 303 | expect(() => test.assertImage({url: EXAMPLE_IMAGE!.url})).not.to.throw(); 304 | 305 | expect(() => test.assertImage({url: EXAMPLE_IMAGE!.url}, true)).to.throw(); 306 | expect(() => test.assertImage({url: 'Bad URL'})).to.throw(); 307 | }); 308 | 309 | it('should get and assert list from latest response', async () => { 310 | initLatestResponse(); 311 | updatedResponseContent(test.latestResponse!, {list: EXAMPLE_LIST}); 312 | assert.equal(test.getList(), EXAMPLE_LIST); 313 | expect(() => test.assertList(EXAMPLE_LIST)).not.to.throw(); 314 | expect(() => test.assertList(EXAMPLE_LIST, true)).not.to.throw(); 315 | expect(() => test.assertList({title: EXAMPLE_LIST!.title})).not.to.throw(); 316 | 317 | expect(() => 318 | test.assertList({title: EXAMPLE_LIST!.title}, true) 319 | ).to.throw(); 320 | expect(() => test.assertList({title: 'Bad List Title'})).to.throw(); 321 | }); 322 | 323 | it('should get and assert collection from latest response', async () => { 324 | initLatestResponse(); 325 | updatedResponseContent(test.latestResponse!, { 326 | collection: EXAMPLE_COLLECTION, 327 | }); 328 | assert.equal(test.getCollection(), EXAMPLE_COLLECTION); 329 | expect(() => test.assertCollection(EXAMPLE_COLLECTION)).not.to.throw(); 330 | expect(() => 331 | test.assertCollection(EXAMPLE_COLLECTION, true) 332 | ).not.to.throw(); 333 | expect(() => 334 | test.assertCollection({ 335 | title: EXAMPLE_COLLECTION!.title, 336 | }) 337 | ).not.to.throw(); 338 | 339 | expect(() => 340 | test.assertCollection({title: EXAMPLE_COLLECTION!.title}, true) 341 | ).to.throw(); 342 | expect(() => 343 | test.assertCollection({ 344 | title: 'Bad Collection Title', 345 | }) 346 | ).to.throw(); 347 | }); 348 | 349 | it('should get and assert table from latest response', async () => { 350 | initLatestResponse(); 351 | updatedResponseContent(test.latestResponse!, {table: EXAMPLE_TABLE}); 352 | assert.equal(test.getTable(), EXAMPLE_TABLE); 353 | expect(() => test.assertTable(EXAMPLE_TABLE)).not.to.throw(); 354 | expect(() => test.assertTable(EXAMPLE_TABLE, true)).not.to.throw(); 355 | expect(() => 356 | test.assertTable({ 357 | title: EXAMPLE_TABLE!.title, 358 | }) 359 | ).not.to.throw(); 360 | 361 | expect(() => 362 | test.assertTable({title: EXAMPLE_TABLE!.title}, true) 363 | ).to.throw(); 364 | expect(() => test.assertTable({title: 'Bad Table Title'})).to.throw(); 365 | }); 366 | 367 | it('should get and assert media from latest response', async () => { 368 | initLatestResponse(); 369 | updatedResponseContent(test.latestResponse!, {media: EXAMPLE_MEDIA}); 370 | assert.equal(test.getMedia(), EXAMPLE_MEDIA); 371 | expect(() => test.assertMedia(EXAMPLE_MEDIA)).not.to.throw(); 372 | expect(() => test.assertMedia(EXAMPLE_MEDIA, true)).not.to.throw(); 373 | expect(() => 374 | test.assertMedia({ 375 | mediaType: EXAMPLE_MEDIA!.mediaType, 376 | }) 377 | ).not.to.throw(); 378 | 379 | expect(() => 380 | test.assertMedia({mediaType: EXAMPLE_MEDIA!.mediaType}, true) 381 | ).to.throw(); 382 | expect(() => test.assertMedia({mediaType: 'MEDIA_STATUS_ACK'})).to.throw(); 383 | }); 384 | 385 | it('should get and assert canvas data from latest response', async () => { 386 | initLatestResponse(); 387 | test.latestResponse!.output!.canvas = EXAMPLE_CANVAS; 388 | assert.equal(test.getCanvasData(), EXAMPLE_CANVAS!.data!); 389 | expect(() => test.assertCanvasData(EXAMPLE_CANVAS!.data!)).not.to.throw(); 390 | expect(() => 391 | test.assertCanvasData(EXAMPLE_CANVAS!.data!, true) 392 | ).not.to.throw(); 393 | expect(() => 394 | test.assertCanvasData([{elem1Key1: 'value'}, {}]) 395 | ).not.to.throw(); 396 | expect(() => 397 | test.assertCanvasData([{elem1Key1: 'value'}, {elem2Key1: 'value2'}]) 398 | ).not.to.throw(); 399 | 400 | expect(() => 401 | test.assertCanvasData([{elem1Key1: 'value'}], true) 402 | ).to.throw(); 403 | expect(() => 404 | test.assertCanvasData([{elem1Key1: 'bad value'}, {elem2Key1: 'value2'}]) 405 | ).to.throw(); 406 | expect(() => test.assertCanvasData([{wrong: 'Bad value'}])).to.throw(); 407 | }); 408 | 409 | it('should get and assert canvas url from latest response', async () => { 410 | initLatestResponse(); 411 | test.latestResponse!.output!.canvas = EXAMPLE_CANVAS; 412 | expect(() => test.assertCanvasURL(EXAMPLE_CANVAS!.url)).not.to.throw(); 413 | assert.equal(test.getCanvasURL(), EXAMPLE_CANVAS!.url); 414 | expect(() => test.assertCanvasURL(EXAMPLE_CANVAS!.url!)).not.to.throw(); 415 | expect(() => test.assertCanvasURL('bad_url')).to.throw(); 416 | }); 417 | 418 | it('should detect when a conversation has not ended', async () => { 419 | initLatestResponse(); 420 | assert.equal(test.getIsConversationEnded(), false); 421 | expect(() => test.assertConversationNotEnded()).not.to.throw(); 422 | expect(() => test.assertConversationEnded()).to.throw(); 423 | }); 424 | 425 | it('should detect when a conversation has ended', async () => { 426 | initLatestResponse(); 427 | test.latestResponse!.diagnostics!.actionsBuilderEvents!.slice( 428 | -1 429 | )[0]!.endConversation = true; 430 | assert.equal(test.getIsConversationEnded(), true); 431 | expect(() => test.assertConversationEnded()).not.to.throw(); 432 | expect(() => test.assertConversationNotEnded()).to.throw(); 433 | }); 434 | }); 435 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/gts/tsconfig-google.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "rootDir": "src/", 7 | "outDir": "dist", 8 | "allowSyntheticDefaultImports": false, 9 | "noImplicitAny": true, 10 | "strictNullChecks": true, 11 | "alwaysStrict": true, 12 | "noUnusedLocals": true, 13 | "resolveJsonModule": true, 14 | "esModuleInterop": true, 15 | }, 16 | "include": [ 17 | "src/**/*.ts", 18 | "test/**/*.ts" 19 | ], 20 | } 21 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "out": "docs", 3 | "exclude": [ 4 | "**/test/**/*.*", 5 | "**/index.ts" 6 | ], 7 | "ignoreCompilerErrors": true, 8 | "disableOutputCheck": true, 9 | "excludeExternals": true, 10 | "excludePrivate": true, 11 | "excludeNotExported": true 12 | } 13 | --------------------------------------------------------------------------------