├── .circleci └── config.yml ├── .env.example ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── pull_request_template.md ├── .gitignore ├── .prettierrc ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── jest.config.js ├── jest.serverless.config.js ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── index.html ├── robots.txt └── twilio-logo.png ├── serverless ├── __tests__ │ ├── assets │ │ └── verify_expiry.ts │ ├── e2e │ │ └── e2e.ts │ ├── functions │ │ ├── app │ │ │ ├── token.ts │ │ │ └── turn-credentials.ts │ │ └── twiml │ │ │ ├── play.ts │ │ │ └── record.ts │ └── setupTests.js ├── constants.js ├── functions │ ├── app │ │ ├── token.js │ │ └── turn-credentials.js │ └── twiml │ │ ├── echo.js │ │ ├── play.js │ │ └── record.js ├── middleware │ └── verify_expiry.private.js └── scripts │ ├── deploy.js │ ├── list.js │ └── remove.js ├── src ├── App.test.tsx ├── App.tsx ├── AudioDeviceTestWidget │ ├── AudioDevice │ │ ├── AudioDevice.test.tsx │ │ └── AudioDevice.tsx │ ├── AudioDeviceTestWidget.test.tsx │ ├── AudioDeviceTestWidget.tsx │ ├── useDevices │ │ ├── useDevices.test.ts │ │ └── useDevices.ts │ └── useTestRunner │ │ ├── useTestRunner.test.ts │ │ └── useTestRunner.ts ├── BrowserCompatibilityWidget │ └── BrowserCompatibilityWidget.tsx ├── CopyResultsWidget │ ├── CopyResultsWidget.test.tsx │ └── CopyResultsWidget.tsx ├── NetworkTestWidget │ ├── EdgeResult │ │ ├── EdgeResult.test.tsx │ │ ├── EdgeResult.tsx │ │ ├── getTooltipContent.test.tsx │ │ └── getTooltipContent.tsx │ ├── NetworkTestWidget.test.tsx │ ├── NetworkTestWidget.tsx │ ├── SettingsModal │ │ ├── SettingsModal.test.tsx │ │ └── SettingsModal.tsx │ ├── Tests │ │ ├── Tests.test.ts │ │ └── Tests.ts │ └── useTestRunner │ │ ├── useTestRunner.test.ts │ │ └── useTestRunner.ts ├── ResultWidget │ ├── ResultIcon │ │ ├── ResultIcon.test.tsx │ │ └── ResultIcon.tsx │ ├── ResultWidget.test.tsx │ ├── ResultWidget.tsx │ ├── __snapshots__ │ │ └── ResultWidget.test.tsx.snap │ └── rows │ │ ├── bandwidth │ │ ├── bandwidth.test.tsx │ │ └── bandwidth.tsx │ │ ├── callSid │ │ └── callSid.tsx │ │ ├── expectedQuality │ │ ├── expectedQuality.test.tsx │ │ └── expectedQuality.tsx │ │ ├── index.tsx │ │ ├── jitter │ │ ├── jitter.test.ts │ │ └── jitter.tsx │ │ ├── latency │ │ ├── latency.test.ts │ │ └── latency.tsx │ │ ├── mediaServers │ │ ├── mediaServers.test.ts │ │ └── mediaServers.tsx │ │ ├── packetLoss │ │ ├── packetLoss.test.ts │ │ └── packetLoss.tsx │ │ ├── shared.tsx │ │ ├── signallingServers │ │ ├── signallingServers.test.ts │ │ └── signallingServers.tsx │ │ ├── timeToConnect │ │ └── timeToConnect.tsx │ │ └── timeToMedia │ │ ├── timeToMedia.test.ts │ │ └── timeToMedia.tsx ├── SummaryWidget │ ├── SummaryWidget.test.tsx │ └── SummaryWidget.tsx ├── common │ ├── Alert │ │ ├── Alert.test.tsx │ │ ├── Alert.tsx │ │ └── __snapshots__ │ │ │ └── Alert.test.tsx.snap │ └── ProgressBar │ │ ├── ProgressBar.test.tsx │ │ └── ProgressBar.tsx ├── constants.ts ├── index.tsx ├── react-app-env.d.ts ├── setupProxy.js ├── setupTests.ts ├── theme.ts ├── types.ts ├── utils.test.ts └── utils.ts └── tsconfig.json /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: circleci/node:12-browsers 6 | working_directory: ~/rtc-diagnostics-react-app 7 | 8 | steps: 9 | - checkout 10 | 11 | - restore_cache: 12 | keys: 13 | - v1-deps-{{ .Branch }}-{{ checksum "package-lock.json" }} 14 | - v1-deps-{{ .Branch }} 15 | - v1-deps 16 | 17 | - run: npm ci 18 | 19 | - save_cache: 20 | key: v1-deps-{{ .Branch }}-{{ checksum "package-lock.json" }} 21 | paths: 22 | - ~/.npm 23 | - ~/.cache 24 | 25 | - run: 26 | name: 'Jest Unit Tests' 27 | command: npm run test:ci 28 | environment: 29 | JEST_JUNIT_OUTPUT_DIR: 'test-reports/jest' 30 | JEST_JUNIT_OUTPUT_NAME: 'results.xml' 31 | JEST_JUNIT_CLASSNAME: '{classname}' 32 | JEST_JUNIT_TITLE: '{title}' 33 | 34 | - store_artifacts: 35 | path: coverage 36 | 37 | - store_test_results: 38 | path: test-reports 39 | 40 | workflows: 41 | version: 2 42 | build-and-deploy: 43 | jobs: 44 | - build: 45 | filters: 46 | tags: 47 | only: /.*/ 48 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 2 | AUTH_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 3 | 4 | # PROXY_URL is used for local development with the 'npm start' command. 5 | # To get a URL, first deploy the app with 'npm run serverless:deploy'. 6 | # Then take note of the URL that is printed to the console. 7 | PROXY_URL=https://rtc-diagnostics-1234-dev.twil.io 8 | 9 | # This VOICE_IDENTITY is optional. Uncomment the following line to use a custom identity 10 | # VOICE_IDENTITY=test_identity 11 | 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior. 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Screenshots** 20 | If applicable, add screenshots to help explain your problem. 21 | 22 | **Environment (please complete the following information):** 23 | - OS: [e.g. Ubuntu 18.04] 24 | - Browser: [e.g. Chrome 80, Firefox 72] 25 | - App Version: [e.g. 0.1.0] 26 | - twilio-client.js Version: [e.g 2.1.0] 27 | - rtc-diagnostics SDK version: [e.g 2.1.0] 28 | - Node.js version: [e.g. 12.14.1] 29 | 30 | **Additional context** 31 | Add any other context about the problem here. -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: feature 6 | assignees: '' 7 | 8 | --- 9 | 10 | **What is the feature that you would like to see in the SDK? Please describe.** 11 | A clear and concise description of the proposed feature. 12 | 13 | **Is your feature request related to a problem? Please describe.** 14 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | **Contributing to Twilio** 4 | 5 | > All third party contributors acknowledge that any contributions they provide will be made under the same open source license that the open source project is provided under. 6 | 7 | - [ ] I acknowledge that all my contributions will be made under the project's license. 8 | 9 | ## Pull Request Details 10 | 11 | ### JIRA link(s): 12 | 13 | - [AHOYAPPS-0000](https://issues.corp.twilio.com/browse/AHOYAPPS-0000) 14 | 15 | ### Description 16 | 17 | A description of what this PR does. 18 | 19 | ## Burndown 20 | 21 | ### Before review 22 | * [ ] Updated CHANGELOG.md if necessary 23 | * [ ] Added unit tests if necessary 24 | * [ ] Updated affected documentation 25 | * [ ] Verified locally with `npm test` 26 | * [ ] Manually sanity tested running locally 27 | * [ ] Included screenshot as PR comment (if needed) 28 | * [ ] Ready for review 29 | 30 | ### Before merge 31 | * [ ] Got one or more +1s 32 | * [ ] Re-tested if necessary -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # IntelliJ IDEA project config 4 | .idea 5 | 6 | # dependencies 7 | node_modules 8 | .pnp 9 | .pnp.js 10 | 11 | # testing 12 | coverage 13 | 14 | # production 15 | build 16 | 17 | # misc 18 | .DS_Store 19 | .env.local 20 | .env.development.local 21 | .env.test.local 22 | .env.production.local 23 | 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | .env 29 | .vscode 30 | 31 | test-reports 32 | junit.xml 33 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | trailingComma: "es5" 2 | singleQuote: true 3 | printWidth: 120 -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at open-source@twilio.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Twilio 2 | 3 | All third party contributors acknowledge that any contributions they provide will be made under the same open source license that the open source project is provided under. -------------------------------------------------------------------------------- /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, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2021 Twilio Inc. 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Voice Diagnostics Tool 2 | 3 | [![CircleCI](https://circleci.com/gh/twilio/rtc-diagnostics-react-app.svg?style=svg)](https://circleci.com/gh/twilio/rtc-diagnostics-react-app) 4 | 5 | The Voice Diagnostics Tool provides connectivity and bandwidth requirements tests towards Twilio’s Programmable Voice servers and audio device tests to check browser VoIP calling readiness. It uses the [RTC Diagnostics SDK](https://github.com/twilio/rtc-diagnostics) and [Twilio Voice JS SDK](https://github.com/twilio/twilio-voice.js) to perform the tests. 6 | 7 | This application uses Programmable Voice and Twilio NTS to perform the tests and will incur charges. See [Programmable Voice pricing](https://www.twilio.com/voice/pricing) and [NTS pricing](https://www.twilio.com/stun-turn/pricing). 8 | 9 | ## Features 10 | 11 | - Bandwidth requirements tests 12 | - VoIP quality measurements 13 | - Support for testing towards all of Twilio Edge locations including Private Interconnect 14 | - Side by side comparison of Edge locations connection results 15 | - JSON formatted report 16 | - Easy Copy of report to clipboard 17 | - Interactive Mic testing 18 | - Interactive Speaker tests 19 | 20 | ## Prerequisites 21 | 22 | - A Twilio account. Sign up for free [here](https://www.twilio.com/try-twilio) 23 | - Node.js v12+ 24 | - NPM v6+ (comes installed with newer Node versions) 25 | 26 | ## Install Dependencies 27 | 28 | Run `npm install` to install all dependencies from NPM. 29 | 30 | If you want to use yarn to install dependencies, first run the [yarn import](https://classic.yarnpkg.com/en/docs/cli/import/) command. This will ensure that yarn installs the package versions that are specified in `package-lock.json`. 31 | 32 | ## Deploy the App to Twilio 33 | 34 | Before deploying the app, add your Twilio Account SID and Auth Token to the `.env` file (see `.env.example` for an example). The app is deployed to Twilio with a single command: 35 | 36 | `$ npm run serverless:deploy` 37 | 38 | This performs the following steps: 39 | 40 | - Builds the React app in the `build/` directory and deploys it to Twilio Serverless. 41 | - Deploys the end points required for performing the tests to Twilio Serverless. These include an access token generator, an NTS token generator, the TwiML App and corresponding TwiML bins 42 | 43 | When deployment has finished, the Twilio Serverless URL for the application will be printed to the console. This URL can be used to access the application: 44 | 45 | `Deployed to: https://rtc-diagnostics-12345-dev.twil.io` 46 | 47 | ## View App URL 48 | 49 | To view the App URL, run the following command: 50 | 51 | `$ npm run serverless:list` 52 | 53 | This will display the URL at which the Application can be accessed. 54 | 55 | ## Local Development 56 | 57 | In order to develop this app on your local machine, you will first need to deploy all needed endpoints to Twilio Serverless. To do this, complete the steps in the "Deploy the App to Twilio" section above. 58 | 59 | Once the endpoints are deployed, add the app's URL to the `.env` file. Then you can start a local development server by running the following command: 60 | 61 | `$ npm run start` 62 | 63 | ## Tests 64 | 65 | Run `npm test` to run all unit tests. 66 | 67 | Run `npm run test:serverless` to run all unit and E2E tests on the Serverless scripts. This requires that your Twilio account credentials are stored in the `.env` file. 68 | 69 | ## Related 70 | 71 | - [Twilio Voice JS SDK](https://github.com/twilio/twilio-voice.js) 72 | - [Twilio RTC Diagnostics SDK](https://github.com/twilio/rtc-diagnostics) 73 | - [Twilio Voice Client JS Quickstart](https://github.com/TwilioDevEd/client-quickstart-js) 74 | - [Twilio Client connectivity requirements](https://www.twilio.com/docs/voice/client/javascript/voice-client-js-and-mobile-sdks-network-connectivity-requirements) 75 | 76 | ## License 77 | 78 | See the [LICENSE](LICENSE) file for details. 79 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | restoreMocks: true, 3 | roots: ['/src'], 4 | transform: { 5 | '^.+\\.tsx?$': 'ts-jest', 6 | }, 7 | testEnvironment: 'jsdom', 8 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$', 9 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 10 | setupFilesAfterEnv: ['/src/setupTests.ts'], 11 | reporters: ['default', 'jest-junit'], 12 | snapshotSerializers: ['enzyme-to-json/serializer'], 13 | }; 14 | -------------------------------------------------------------------------------- /jest.serverless.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/serverless'], 3 | transform: { 4 | '^.+\\.tsx?$': 'ts-jest', 5 | }, 6 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$', 7 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 8 | setupFilesAfterEnv: ['/serverless/__tests__/setupTests.js'], 9 | reporters: ['default', 'jest-junit'], 10 | testEnvironment: 'node', 11 | testTimeout: 60000, 12 | }; 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rtc-diagnostics-react-app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@material-ui/core": "^4.10.0", 7 | "@material-ui/icons": "^4.9.1", 8 | "@twilio-labs/serverless-api": "^2.0.1", 9 | "@twilio/rtc-diagnostics": "^1.0.0", 10 | "@twilio/voice-sdk": "^2.1.1", 11 | "@types/node": "^12.12.39", 12 | "@types/react": "^16.9.35", 13 | "@types/react-dom": "^16.9.8", 14 | "cli-ux": "^5.4.6", 15 | "dotenv": "^8.2.0", 16 | "lodash": "^4.17.21", 17 | "loglevel": "^1.6.8", 18 | "nanoid": "^3.1.31", 19 | "react": "^16.13.1", 20 | "react-dom": "^16.13.1", 21 | "react-scripts": "5.0.0", 22 | "twilio": "^3.80.0", 23 | "typescript": "^3.7.5", 24 | "util": "^0.12.4" 25 | }, 26 | "scripts": { 27 | "start": "react-scripts start", 28 | "build": "react-scripts build", 29 | "test": "jest", 30 | "eject": "react-scripts eject", 31 | "lint": "eslint src/**/*.{ts,tsx}", 32 | "test:serverless": "jest -c jest.serverless.config.js", 33 | "test:ci": "npm run test:ci:app && npm run test:ci:serverless", 34 | "test:ci:app": "jest --ci --runInBand --reporters=default --reporters=jest-junit --coverage", 35 | "test:ci:serverless": "jest -c jest.serverless.config.js --ci --runInBand", 36 | "serverless:deploy": "npm run build && node serverless/scripts/deploy.js", 37 | "serverless:remove": "node serverless/scripts/remove.js", 38 | "serverless:list": "node serverless/scripts/list.js" 39 | }, 40 | "eslintConfig": { 41 | "extends": "react-app", 42 | "rules": { 43 | "no-shadow": "off", 44 | "@typescript-eslint/no-shadow": [ 45 | "warn" 46 | ] 47 | } 48 | }, 49 | "browserslist": { 50 | "production": [ 51 | ">0.2%", 52 | "not dead", 53 | "not op_mini all" 54 | ], 55 | "development": [ 56 | "last 1 chrome version", 57 | "last 1 firefox version", 58 | "last 1 safari version" 59 | ] 60 | }, 61 | "devDependencies": { 62 | "@testing-library/jest-dom": "^4.2.4", 63 | "@testing-library/react": "^9.5.0", 64 | "@testing-library/react-hooks": "^3.3.0", 65 | "@testing-library/user-event": "^7.2.1", 66 | "@types/enzyme": "^3.10.5", 67 | "@types/jest": "^27.5.2", 68 | "@types/jsonwebtoken": "^8.5.0", 69 | "@types/lodash.set": "^4.3.6", 70 | "enzyme": "^3.11.0", 71 | "enzyme-adapter-react-16": "^1.15.2", 72 | "enzyme-to-json": "^3.5.0", 73 | "http-proxy-middleware": "^1.0.4", 74 | "husky": "^4.2.5", 75 | "jest-junit": "^12.1.0", 76 | "jsonwebtoken": "^8.5.1", 77 | "lint-staged": "^10.2.2", 78 | "mocha": "^7.1.2", 79 | "mocha-junit-reporter": "^1.23.3", 80 | "prettier": "^2.0.5", 81 | "stdout-stderr": "^0.1.13", 82 | "superagent": "^5.2.2", 83 | "ts-jest": "^27.0.1" 84 | }, 85 | "lint-staged": { 86 | "src/**/*.{js,jsx,ts,tsx,json,css,scss,md}": [ 87 | "prettier --write", 88 | "git add" 89 | ] 90 | }, 91 | "husky": { 92 | "hooks": { 93 | "pre-commit": "lint-staged" 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/rtc-diagnostics-react-app/a50c286d7d6da82ee10a4d0da1e8c3190f747811/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 22 | Twilio RTC Diagnostics App 23 | 24 | 25 | 26 |
27 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /public/twilio-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio/rtc-diagnostics-react-app/a50c286d7d6da82ee10a4d0da1e8c3190f747811/public/twilio-logo.png -------------------------------------------------------------------------------- /serverless/__tests__/assets/verify_expiry.ts: -------------------------------------------------------------------------------- 1 | const { handler } = jest.requireActual('../../../serverless/middleware/verify_expiry.private'); 2 | 3 | Date.now = () => 2; 4 | 5 | describe('the verify_expiry asset', () => { 6 | it('should not call the callback function when the current date is less than APP_EXPIRY', () => { 7 | const mockCallback = jest.fn(); 8 | handler( 9 | { 10 | APP_EXPIRY: 3, 11 | }, 12 | {}, 13 | mockCallback 14 | ); 15 | expect(mockCallback).not.toHaveBeenCalled(); 16 | }); 17 | 18 | it('should call the callback function when the current date is greater than APP_EXPIRY', () => { 19 | const mockCallback = jest.fn(); 20 | handler( 21 | { 22 | APP_EXPIRY: 1, 23 | }, 24 | {}, 25 | mockCallback 26 | ); 27 | expect(mockCallback).toHaveBeenCalledWith(null, { 28 | body: { 29 | error: { 30 | explanation: 31 | 'The passcode used to validate application users has expired. Re-deploy the application to refresh the passcode.', 32 | message: 'passcode expired', 33 | }, 34 | }, 35 | headers: { 'Content-Type': 'application/json' }, 36 | statusCode: 401, 37 | }); 38 | }); 39 | }); 40 | 41 | // To avoid the 'All files must be modules when the '--isolatedModules' flag is provided.' error 42 | export default null; 43 | -------------------------------------------------------------------------------- /serverless/__tests__/e2e/e2e.ts: -------------------------------------------------------------------------------- 1 | import runDeploy from '../../scripts/deploy'; 2 | import runRemove from '../../scripts/remove'; 3 | import jwt from 'jsonwebtoken'; 4 | import constants from '../../constants'; 5 | import Twilio from 'twilio'; 6 | 7 | const client = Twilio(process.env.ACCOUNT_SID, process.env.AUTH_TOKEN); 8 | 9 | constants.SERVICE_NAME = 'rtc-diagnostics-e2e-test'; 10 | 11 | const { stdout } = require('stdout-stderr'); 12 | const superagent = require('superagent'); 13 | 14 | describe('the serverless endpoints', () => { 15 | let appURL: string; 16 | 17 | beforeAll(async () => { 18 | stdout.start(); 19 | await runDeploy(); 20 | stdout.stop(); 21 | expect(stdout.output).toContain('App deployed to: '); 22 | appURL = stdout.output.match(/App deployed to: (.+)\n/)[1]; 23 | }); 24 | 25 | afterAll(async () => { 26 | stdout.start(); 27 | await runRemove(); 28 | stdout.stop(); 29 | 30 | const services = await client.serverless.services.list(); 31 | const app = services.find((service) => service.friendlyName.includes(constants.SERVICE_NAME)); 32 | expect(app).toBe(undefined); 33 | }); 34 | 35 | describe('the app URL', () => { 36 | it('should contain random alphanumeric characters', () => { 37 | const regex = new RegExp(`https://${constants.SERVICE_NAME}-[\\w\\d]{8}-[\\w\\d]+-dev.twil.io`); 38 | expect(appURL).toMatch(regex); 39 | }); 40 | }); 41 | 42 | describe('the token function', () => { 43 | it('should return a valid access token', async () => { 44 | const { body } = await superagent.get(`${appURL}/app/token`); 45 | const token = jwt.decode(body.token) as { [key: string]: any }; 46 | expect(token.grants.identity).toEqual(constants.VOICE_IDENTITY); 47 | expect(token.grants.voice.outgoing.application_sid).toMatch(/^AP/); 48 | }); 49 | }); 50 | 51 | describe('the turn-credentials function', () => { 52 | it('should return turn credentials', async () => { 53 | const { body } = await superagent.get(`${appURL}/app/turn-credentials`); 54 | expect(body).toEqual( 55 | expect.objectContaining({ 56 | password: '[Redacted]', 57 | ttl: '30', 58 | username: expect.any(String), 59 | accountSid: expect.any(String), 60 | iceServers: expect.arrayContaining([ 61 | { 62 | url: expect.any(String), 63 | urls: expect.any(String), 64 | }, 65 | ]), 66 | }) 67 | ); 68 | }); 69 | }); 70 | 71 | describe('the "play" TwiML function', () => { 72 | it('should return the correct TwiML', async () => { 73 | const { text } = await superagent.get(`${appURL}/twiml/play?RecordingUrl=testurl`); 74 | expect(text).toMatchInlineSnapshot( 75 | `"You said:testurlNow waiting for a few seconds to gather audio performance metrics.Hanging up now."` 76 | ); 77 | }); 78 | }); 79 | 80 | describe('the "record" TwiML function', () => { 81 | it('should return the correct TwiML', async () => { 82 | const { text } = await superagent.get(`${appURL}/twiml/record`); 83 | expect(text).toEqual( 84 | `Record a message in 3, 2, 1Did not detect a message to record` 85 | ); 86 | }); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /serverless/__tests__/functions/app/token.ts: -------------------------------------------------------------------------------- 1 | import { handler } from '../../../functions/app/token'; 2 | import jwt, { VerifyCallback } from 'jsonwebtoken'; 3 | 4 | const mockContext = { 5 | API_KEY: 'mockkey', 6 | API_SECRET: 'mocksecret', 7 | ACCOUNT_SID: 'mocksid', 8 | TWIML_APP_SID: 'mockappsid', 9 | VOICE_IDENTITY: 'test-identity', 10 | }; 11 | 12 | Date.now = () => 1589568597000; 13 | 14 | describe('the token function', () => { 15 | it('should return a valid json web token', () => { 16 | const mockCallback = jest.fn(); 17 | handler(mockContext, {}, mockCallback); 18 | expect(mockCallback).toHaveBeenCalledWith(null, expect.objectContaining({ token: expect.any(String) })); 19 | 20 | const token = mockCallback.mock.calls[0][1].token; 21 | jwt.verify(token, mockContext.API_SECRET, ((err, decoded) => { 22 | expect(err).toBeNull(); 23 | expect(decoded).toMatchInlineSnapshot(` 24 | Object { 25 | "exp": 1589568657, 26 | "grants": Object { 27 | "identity": "test-identity", 28 | "voice": Object { 29 | "outgoing": Object { 30 | "application_sid": "mockappsid", 31 | }, 32 | }, 33 | }, 34 | "iat": 1589568597, 35 | "iss": "mockkey", 36 | "jti": "mockkey-1589568597", 37 | "sub": "mocksid", 38 | } 39 | `); 40 | })); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /serverless/__tests__/functions/app/turn-credentials.ts: -------------------------------------------------------------------------------- 1 | import { handler } from '../../../functions/app/turn-credentials'; 2 | 3 | const mockClient = { 4 | tokens: { 5 | create: jest.fn(() => Promise.resolve('mock token')), 6 | }, 7 | }; 8 | 9 | describe('the turn-credentials function', () => { 10 | it('should return turn credentials', (done) => { 11 | const mockCallback = jest.fn(); 12 | handler({ getTwilioClient: () => mockClient }, {}, mockCallback); 13 | expect(mockClient.tokens.create).toHaveBeenCalledWith({ ttl: 30 }); 14 | setImmediate(() => { 15 | expect(mockCallback).toHaveBeenCalledWith(null, 'mock token'); 16 | done(); 17 | }); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /serverless/__tests__/functions/twiml/play.ts: -------------------------------------------------------------------------------- 1 | import { handler } from '../../../functions/twiml/play'; 2 | 3 | describe('the "play" TwiML function', () => { 4 | it('should render the correct TwiML', () => { 5 | const mockCallback = jest.fn(); 6 | handler({}, { RecordingUrl: 'testurl.com/recording' }, mockCallback); 7 | expect(mockCallback.mock.calls[0][1].toString()).toMatchInlineSnapshot( 8 | `"You said:testurl.com/recordingNow waiting for a few seconds to gather audio performance metrics.Hanging up now."` 9 | ); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /serverless/__tests__/functions/twiml/record.ts: -------------------------------------------------------------------------------- 1 | import { handler } from '../../../functions/twiml/record'; 2 | 3 | describe('the record TwiML function', () => { 4 | it('should "render" the correct TwiML', () => { 5 | const mockCallback = jest.fn(); 6 | handler({ DOMAIN_NAME: 'mock-domain.com' }, {}, mockCallback); 7 | 8 | expect(mockCallback.mock.calls[0][1].toString()).toMatchInlineSnapshot( 9 | `"Record a message in 3, 2, 1Did not detect a message to record"` 10 | ); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /serverless/__tests__/setupTests.js: -------------------------------------------------------------------------------- 1 | class Response { 2 | constructor() { 3 | this.statusCode = null; 4 | this.body = null; 5 | this.headers = {}; 6 | } 7 | 8 | setStatusCode(code) { 9 | this.statusCode = code; 10 | } 11 | 12 | setBody(body) { 13 | this.body = body; 14 | } 15 | 16 | appendHeader(key, value) { 17 | this.headers[key] = value; 18 | } 19 | } 20 | 21 | global.Twilio = require('twilio'); 22 | global.Twilio.Response = Response; 23 | 24 | const verifyExpiryPath = `${__dirname}/../middleware/verify_expiry.private.js`; 25 | 26 | global.Runtime = { 27 | getAssets: () => ({ 28 | '/verify_expiry.js': { 29 | path: verifyExpiryPath, 30 | }, 31 | }), 32 | }; 33 | 34 | // Mocking this as a no-op since this function is tested in '__tests__/middleware/verify_expiry.ts'. 35 | jest.mock(verifyExpiryPath, () => ({ handler: () => {} })); 36 | 37 | process.on('unhandledRejection', (err) => { 38 | console.error(err); 39 | throw err; 40 | }); 41 | -------------------------------------------------------------------------------- /serverless/constants.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | 3 | module.exports = { 4 | SERVICE_NAME: 'rtc-diagnostics', 5 | API_KEY_NAME: 'RTC Diagnostics Key', 6 | TWIML_APP_NAME: 'Test TwiML App', 7 | VOICE_IDENTITY: process.env.VOICE_IDENTITY || 'RTC_Diagnostics_Test_Identity', 8 | }; 9 | -------------------------------------------------------------------------------- /serverless/functions/app/token.js: -------------------------------------------------------------------------------- 1 | exports.handler = function (context, event, callback) { 2 | const verifyExpiry = require(Runtime.getAssets()['/verify_expiry.js'].path); 3 | verifyExpiry.handler(context, event, callback); 4 | 5 | const AccessToken = Twilio.jwt.AccessToken; 6 | const VoiceGrant = AccessToken.VoiceGrant; 7 | 8 | const voiceGrant = new VoiceGrant({ 9 | outgoingApplicationSid: context.TWIML_APP_SID, 10 | }); 11 | 12 | const token = new AccessToken(context.ACCOUNT_SID, context.API_KEY, context.API_SECRET, { 13 | ttl: 60, 14 | }); 15 | token.addGrant(voiceGrant); 16 | token.identity = context.VOICE_IDENTITY; 17 | 18 | callback(null, { token: token.toJwt() }); 19 | }; 20 | -------------------------------------------------------------------------------- /serverless/functions/app/turn-credentials.js: -------------------------------------------------------------------------------- 1 | exports.handler = function (context, event, callback) { 2 | const verifyExpiry = require(Runtime.getAssets()['/verify_expiry.js'].path); 3 | verifyExpiry.handler(context, event, callback); 4 | 5 | const client = context.getTwilioClient(); 6 | client.tokens.create({ ttl: 30 }).then((token) => callback(null, token)); 7 | }; 8 | -------------------------------------------------------------------------------- /serverless/functions/twiml/echo.js: -------------------------------------------------------------------------------- 1 | exports.handler = function (context, event, callback) { 2 | const twiml = new Twilio.twiml.VoiceResponse(); 3 | twiml.echo(); 4 | callback(null, twiml); 5 | }; 6 | -------------------------------------------------------------------------------- /serverless/functions/twiml/play.js: -------------------------------------------------------------------------------- 1 | exports.handler = function (context, event, callback) { 2 | const twiml = new Twilio.twiml.VoiceResponse(); 3 | twiml.say('You said:'); 4 | twiml.play(event.RecordingUrl, { loop: '1' }); 5 | twiml.say('Now waiting for a few seconds to gather audio performance metrics.'); 6 | twiml.pause({ length: 3 }); 7 | twiml.say('Hanging up now.'); 8 | callback(null, twiml); 9 | }; 10 | -------------------------------------------------------------------------------- /serverless/functions/twiml/record.js: -------------------------------------------------------------------------------- 1 | exports.handler = function (context, event, callback) { 2 | const twiml = new Twilio.twiml.VoiceResponse(); 3 | twiml.say('Record a message in 3, 2, 1'); 4 | twiml.record({ maxLength: 5, action: `https://${context.DOMAIN_NAME}/twiml/play` }); 5 | twiml.say('Did not detect a message to record'); 6 | callback(null, twiml); 7 | }; 8 | -------------------------------------------------------------------------------- /serverless/middleware/verify_expiry.private.js: -------------------------------------------------------------------------------- 1 | exports.handler = function (context, event, callback) { 2 | if (Date.now() > context.APP_EXPIRY) { 3 | const response = new Twilio.Response(); 4 | response.appendHeader('Content-Type', 'application/json'); 5 | response.setStatusCode(401); 6 | response.setBody({ 7 | error: { 8 | message: 'passcode expired', 9 | explanation: 10 | 'The passcode used to validate application users has expired. Re-deploy the application to refresh the passcode.', 11 | }, 12 | }); 13 | return callback(null, response); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /serverless/scripts/deploy.js: -------------------------------------------------------------------------------- 1 | const { TwilioServerlessApiClient } = require('@twilio-labs/serverless-api'); 2 | const { getListOfFunctionsAndAssets } = require('@twilio-labs/serverless-api/dist/utils/fs'); 3 | const cli = require('cli-ux').default; 4 | const constants = require('../constants'); 5 | const { customAlphabet } = require('nanoid'); 6 | const viewApp = require(`${__dirname}/list.js`); 7 | 8 | const getRandomString = customAlphabet('abcdefghijklmnopqrstuvwxyz1234567890', 8); 9 | 10 | require('dotenv').config(); 11 | 12 | const client = require('twilio')(process.env.ACCOUNT_SID, process.env.AUTH_TOKEN); 13 | const serverlessClient = new TwilioServerlessApiClient({ 14 | accountSid: process.env.ACCOUNT_SID, 15 | authToken: process.env.AUTH_TOKEN, 16 | }); 17 | 18 | async function deployFunctions() { 19 | cli.action.start('Creating Api Key'); 20 | const api_key = await client.newKeys.create({ friendlyName: constants.API_KEY_NAME }); 21 | cli.action.start('Deploying assets and functions'); 22 | 23 | const { assets, functions } = await getListOfFunctionsAndAssets(__dirname, { 24 | functionsFolderNames: ['../functions'], 25 | assetsFolderNames: ['../../build'], 26 | }); 27 | 28 | // Calling 'getListOfFunctionsAndAssets' twice is necessary because it only gets the assets from 29 | // the first matching folder in the array 30 | const { assets: fnAssets } = await getListOfFunctionsAndAssets(__dirname, { 31 | assetsFolderNames: ['../middleware'], 32 | }); 33 | 34 | assets.push(...fnAssets); 35 | 36 | const indexHTML = assets.find((asset) => asset.name.includes('index.html')); 37 | 38 | if (indexHTML) { 39 | assets.push({ 40 | ...indexHTML, 41 | path: '/', 42 | name: '/', 43 | }); 44 | } 45 | 46 | return serverlessClient.deployProject({ 47 | env: { 48 | API_KEY: api_key.sid, 49 | API_SECRET: api_key.secret, 50 | VOICE_IDENTITY: constants.VOICE_IDENTITY, 51 | APP_EXPIRY: Date.now() + 1000 * 60 * 60 * 24 * 7, // One week 52 | }, 53 | pkgJson: {}, 54 | functionsEnv: 'dev', 55 | assets, 56 | functions, 57 | serviceName: `${constants.SERVICE_NAME}-${getRandomString()}`, 58 | }); 59 | } 60 | 61 | function createTwiMLApp(domain) { 62 | cli.action.start('Creating TwiML App'); 63 | return client.applications.create({ 64 | voiceMethod: 'GET', 65 | voiceUrl: `https://${domain}/twiml/echo`, 66 | friendlyName: constants.TWIML_APP_NAME, 67 | }); 68 | } 69 | 70 | async function createTwiMLAppSidVariable(app, TwiMLApp) { 71 | cli.action.start('Setting environment variables'); 72 | const appInstance = await client.serverless.services(app.serviceSid); 73 | const environment = await appInstance.environments(app.environmentSid); 74 | return await environment.variables.create({ key: 'TWIML_APP_SID', value: TwiMLApp.sid }); 75 | } 76 | 77 | async function deploy() { 78 | const app = await deployFunctions(); 79 | const TwiMLApp = await createTwiMLApp(app.domain); 80 | await createTwiMLAppSidVariable(app, TwiMLApp); 81 | 82 | cli.action.stop(); 83 | await viewApp(); 84 | } 85 | 86 | if (require.main === module) { 87 | deploy(); 88 | } else { 89 | module.exports = deploy; 90 | } 91 | -------------------------------------------------------------------------------- /serverless/scripts/list.js: -------------------------------------------------------------------------------- 1 | const constants = require('../constants'); 2 | require('dotenv').config(); 3 | const client = require('twilio')(process.env.ACCOUNT_SID, process.env.AUTH_TOKEN); 4 | 5 | async function findApp() { 6 | const services = await client.serverless.services.list(); 7 | return services.find((service) => service.friendlyName.includes(constants.SERVICE_NAME)); 8 | } 9 | 10 | async function getAppInfo() { 11 | const app = await findApp(); 12 | if (!app) return null; 13 | 14 | const appInstance = client.serverless.services(app.sid); 15 | const [environment] = await appInstance.environments.list(); 16 | const variables = await appInstance.environments(environment.sid).variables.list(); 17 | const expiryVar = variables.find((v) => v.key === 'APP_EXPIRY'); 18 | const expiryDate = new Date(Number(expiryVar.value)).toString(); 19 | 20 | console.log('App deployed to: https://' + environment.domainName); 21 | console.log(`This URL is for demo purposes only. It will expire on ${expiryDate}`); 22 | } 23 | 24 | if (require.main === module) { 25 | getAppInfo(); 26 | } else { 27 | module.exports = getAppInfo; 28 | } 29 | -------------------------------------------------------------------------------- /serverless/scripts/remove.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const client = require('twilio')(process.env.ACCOUNT_SID, process.env.AUTH_TOKEN); 3 | const cli = require('cli-ux').default; 4 | const constants = require('../constants'); 5 | 6 | async function remove() { 7 | cli.action.start('Removing service'); 8 | const services = await client.serverless.services.list(); 9 | const app = services.find((service) => service.friendlyName.includes(constants.SERVICE_NAME)); 10 | if (app) { 11 | await client.serverless.services(app.sid).remove(); 12 | } 13 | 14 | cli.action.start('Removing TwiML App'); 15 | const TwiMLApps = await client.applications.list(); 16 | const TwiMLApp = TwiMLApps.find((item) => item.friendlyName === constants.TWIML_APP_NAME); 17 | if (TwiMLApp) { 18 | await client.applications(TwiMLApp.sid).remove(); 19 | } 20 | 21 | cli.action.start('Removing Api Key'); 22 | const keys = await client.keys.list(); 23 | const app_key = keys.find((key) => key.friendlyName === constants.API_KEY_NAME); 24 | if (app_key) { 25 | client.keys(app_key.sid).remove(); 26 | } 27 | 28 | cli.action.stop(); 29 | } 30 | 31 | if (require.main === module) { 32 | remove(); 33 | } else { 34 | module.exports = remove; 35 | } 36 | -------------------------------------------------------------------------------- /src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import App from './App'; 3 | import BrowserCompatibilityWidget from './BrowserCompatibilityWidget/BrowserCompatibilityWidget'; 4 | import AudioDeviceTestWidget from './AudioDeviceTestWidget/AudioDeviceTestWidget'; 5 | import NetworkTestWidget from './NetworkTestWidget/NetworkTestWidget'; 6 | import { shallow } from 'enzyme'; 7 | 8 | let mockDevice = { isSupported: true }; 9 | jest.mock('@twilio/voice-sdk', () => ({ 10 | get Call() { 11 | return { Codec: { PCMU: 'pcmu', Opus: 'opus' } }; 12 | }, 13 | get Device() { 14 | return mockDevice; 15 | }, 16 | })); 17 | 18 | // These components try to access real properties of @twilio/voice-sdk, but it doesn't 19 | // work since @twilio/voice-sdk is mocked. These components dont actually need to be 20 | // rendered, so we will mock them here. 21 | jest.mock('./NetworkTestWidget/NetworkTestWidget', () => () => null); 22 | jest.mock('./ResultWidget/ResultWidget', () => () => null); 23 | 24 | describe('the App component', () => { 25 | describe('when the browser is supported', () => { 26 | beforeAll(() => (mockDevice.isSupported = true)); 27 | 28 | it('should not render the BrowserCompatibilityWidget component', () => { 29 | const wrapper = shallow(); 30 | expect(wrapper.find(BrowserCompatibilityWidget).exists()).toBe(false); 31 | }); 32 | 33 | it('should render the NetworkTestWidget component', () => { 34 | const wrapper = shallow(); 35 | expect(wrapper.find(NetworkTestWidget).exists()).toBe(true); 36 | }); 37 | 38 | it('should render the AudioDeviceTestWidget component', () => { 39 | const wrapper = shallow(); 40 | expect(wrapper.find(AudioDeviceTestWidget).exists()).toBe(true); 41 | }); 42 | }); 43 | 44 | describe('when the browser is not supported', () => { 45 | beforeAll(() => (mockDevice.isSupported = false)); 46 | 47 | it('should render the BrowserCompatibilityWidget component', () => { 48 | const wrapper = shallow(); 49 | expect(wrapper.find(BrowserCompatibilityWidget).exists()).toBe(true); 50 | }); 51 | 52 | it('should not render the NetworkTestWidget component', () => { 53 | const wrapper = shallow(); 54 | expect(wrapper.find(NetworkTestWidget).exists()).toBe(false); 55 | }); 56 | 57 | it('should not render the AudioDeviceTestWidget component', () => { 58 | const wrapper = shallow(); 59 | expect(wrapper.find(AudioDeviceTestWidget).exists()).toBe(false); 60 | }); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { getLogger } from 'loglevel'; 3 | import { AppBar, Container, Toolbar, Grid, Paper, CssBaseline, makeStyles, Typography } from '@material-ui/core'; 4 | import AudioDeviceTestWidget from './AudioDeviceTestWidget/AudioDeviceTestWidget'; 5 | import BrowserCompatibilityWidget from './BrowserCompatibilityWidget/BrowserCompatibilityWidget'; 6 | import CopyResultsWidget from './CopyResultsWidget/CopyResultsWidget'; 7 | import { Device } from '@twilio/voice-sdk'; 8 | import { getJSON } from './utils'; 9 | import { APP_NAME, LOG_LEVEL } from './constants'; 10 | import NetworkTestWidget from './NetworkTestWidget/NetworkTestWidget'; 11 | import ResultWidget from './ResultWidget/ResultWidget'; 12 | import SummaryWidget from './SummaryWidget/SummaryWidget'; 13 | 14 | const log = getLogger(APP_NAME); 15 | log.setLevel(LOG_LEVEL); 16 | 17 | const useStyles = makeStyles({ 18 | container: { 19 | marginTop: '2em', 20 | }, 21 | paper: { 22 | padding: '1.5em', 23 | }, 24 | tableHeader: { 25 | display: 'flex', 26 | justifyContent: 'space-between', 27 | alignItems: 'center', 28 | padding: '1em', 29 | }, 30 | }); 31 | 32 | function App() { 33 | const classes = useStyles(); 34 | const [results, setResults] = useState(); 35 | 36 | function getTURNCredentials() { 37 | return getJSON('app/turn-credentials').then((res) => res.iceServers as RTCIceServer[]); 38 | } 39 | 40 | function getVoiceToken() { 41 | return getJSON('app/token').then((res) => res.token as string); 42 | } 43 | 44 | return ( 45 |
46 | 47 | 48 | 49 | Logo 50 | 51 | 52 | 53 | 54 | {!Device.isSupported && ( 55 | 56 | 57 | 58 | )} 59 | {Device.isSupported && ( 60 | <> 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | setResults(res)} 72 | /> 73 | 74 | 75 | 76 | 77 | 78 | {results && ( 79 |
80 | Test Results: 81 | 82 |
83 | )} 84 | 85 |
86 |
87 | 88 | )} 89 |
90 |
91 |
92 | ); 93 | } 94 | 95 | export default App; 96 | -------------------------------------------------------------------------------- /src/AudioDeviceTestWidget/AudioDevice/AudioDevice.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Select, Typography } from '@material-ui/core'; 3 | import { render } from '@testing-library/react'; 4 | import { mount, shallow } from 'enzyme'; 5 | 6 | import AudioDevice from './AudioDevice'; 7 | import ProgressBar from '../../common/ProgressBar/ProgressBar'; 8 | 9 | const mediaInfoProps = { groupId: 'foo', toJSON: () => {} }; 10 | const mockDevices = [{ 11 | deviceId: 'input1', 12 | label: 'deviceinput1', 13 | kind: 'audioinput', 14 | ...mediaInfoProps, 15 | },{ 16 | deviceId: 'output1', 17 | label: 'deviceoutput1', 18 | kind: 'audiooutput', 19 | ...mediaInfoProps, 20 | }]; 21 | 22 | jest.mock('../useDevices/useDevices', () => ({ 23 | useDevices: () => mockDevices, 24 | })); 25 | 26 | describe('the AudioDevice component', () => { 27 | const noop = () => {}; 28 | let originalAudio: any; 29 | let mockAudio: any; 30 | 31 | beforeEach(() => { 32 | mockAudio = { 33 | prototype: { 34 | setSinkId: true 35 | } 36 | }; 37 | originalAudio = global.Audio; 38 | global.Audio = mockAudio; 39 | }); 40 | 41 | afterEach(() => { 42 | global.Audio = originalAudio; 43 | }); 44 | 45 | it('should render default audio output if audio redirect is not supported', () => { 46 | mockAudio.prototype.setSinkId = false; 47 | const wrapper = shallow(); 48 | expect(wrapper.find(Select).exists()).toBeFalsy(); 49 | expect(wrapper.find(Typography).at(2).text()).toEqual('System Default Audio Output'); 50 | }); 51 | 52 | describe('props.disabled', () => { 53 | it('should disable dropdown if disabled=true', () => { 54 | const { container } = render(); 55 | const el = container.querySelector('.MuiInputBase-root') as HTMLDivElement; 56 | expect(el.className.includes('Mui-disabled')).toBeTruthy(); 57 | }); 58 | it('should not disable dropdown if disabled=false', () => { 59 | const { container } = render(); 60 | const el = container.querySelector('.MuiInputBase-root') as HTMLDivElement; 61 | expect(el.className.includes('Mui-disabled')).toBeFalsy(); 62 | }); 63 | }); 64 | 65 | describe('props.level', () => { 66 | it('should set progress to 0 if level is 0', () => { 67 | const wrapper = shallow(); 68 | expect(wrapper.find(ProgressBar).prop('position')).toEqual(1); 69 | }); 70 | it('should set progress to 20 if level is 20', () => { 71 | const wrapper = shallow(); 72 | expect(wrapper.find(ProgressBar).prop('position')).toEqual(20); 73 | }); 74 | }); 75 | 76 | describe('props.kind', () => { 77 | it('should render input devices if kind is audioinput', () => { 78 | const wrapper = shallow(); 79 | expect(wrapper.find(Select).at(0).text()).toEqual('deviceinput1'); 80 | }); 81 | it('should render output devices if kind is audiooutput', () => { 82 | const wrapper = shallow(); 83 | expect(wrapper.find(Select).at(0).text()).toEqual('deviceoutput1'); 84 | }); 85 | }); 86 | 87 | describe('props.onDeviceChange', () => { 88 | let onDeviceChange: () => any; 89 | 90 | beforeEach(() => { 91 | onDeviceChange = jest.fn(); 92 | }); 93 | 94 | it('should trigger onDeviceChange when devices are present', () => { 95 | mount(); 96 | expect(onDeviceChange).toHaveBeenCalled(); 97 | }); 98 | 99 | it('should trigger onDeviceChange when a new device is selected', () => { 100 | mockDevices.push({ 101 | deviceId: 'input2', 102 | label: 'deviceinput2', 103 | kind: 'audioinput', 104 | ...mediaInfoProps, 105 | }); 106 | 107 | const wrapper = mount(); 108 | expect(onDeviceChange).toHaveBeenCalledWith('input1'); 109 | 110 | const selectEl = wrapper.find(Select).find('input'); 111 | selectEl.simulate('change', { target: { value: 'input2' } }); 112 | expect(onDeviceChange).toHaveBeenCalledWith('input2'); 113 | expect(onDeviceChange).toHaveBeenCalledTimes(2); 114 | }); 115 | }); 116 | }); 117 | -------------------------------------------------------------------------------- /src/AudioDeviceTestWidget/AudioDevice/AudioDevice.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useState } from 'react'; 2 | import { FormControl, InputLabel, MenuItem, Select, Typography } from '@material-ui/core'; 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | 5 | import ProgressBar from '../../common/ProgressBar/ProgressBar'; 6 | import { useDevices } from '../useDevices/useDevices'; 7 | 8 | const labels = { 9 | audioinput: { 10 | audioLevelText: 'Input level', 11 | deviceLabelHeader: 'Input device', 12 | headerText: 'Microphone', 13 | }, 14 | audiooutput: { 15 | audioLevelText: 'Output level', 16 | deviceLabelHeader: 'Output device', 17 | headerText: 'Speaker', 18 | } 19 | }; 20 | 21 | const useStyles = makeStyles(() => ({ 22 | audioLevelContainer: { 23 | display: 'flex', 24 | alignItems: 'center', 25 | }, 26 | form: { 27 | margin: '1em 0', 28 | minWidth: 200, 29 | }, 30 | deviceLabelContainer: { 31 | margin: '1em 0', 32 | '&> *': { 33 | marginBottom: '0.3em' 34 | } 35 | } 36 | })); 37 | 38 | interface AudioDeviceProps { 39 | disabled: boolean; 40 | level: number; 41 | kind: 'audioinput' | 'audiooutput'; 42 | onDeviceChange: (value: string) => void; 43 | } 44 | 45 | export default function AudioDevice({ disabled, level, kind, onDeviceChange }: AudioDeviceProps) { 46 | const classes = useStyles(); 47 | const devices = useDevices().filter(device => device.kind === kind); 48 | const [selectedDevice, setSelectedDevice] = useState(''); 49 | 50 | const { audioLevelText, deviceLabelHeader, headerText } = labels[kind]; 51 | const noAudioRedirect = !Audio.prototype.setSinkId && kind === 'audiooutput'; 52 | 53 | const updateSelectedDevice = useCallback((value: string) => { 54 | onDeviceChange(value); 55 | setSelectedDevice(value); 56 | }, [onDeviceChange, setSelectedDevice]); 57 | 58 | useEffect(() => { 59 | const hasSelectedDevice = devices.some((device) => device.deviceId === selectedDevice); 60 | if (devices.length && !hasSelectedDevice) { 61 | updateSelectedDevice(devices[0].deviceId); 62 | } 63 | }, [devices, selectedDevice, updateSelectedDevice]); 64 | 65 | return ( 66 |
67 | {headerText} 68 | 69 | {noAudioRedirect && ( 70 |
71 | {deviceLabelHeader} 72 | System Default Audio Output 73 |
74 | )} 75 | 76 | {!noAudioRedirect && ( 77 | 78 | {deviceLabelHeader} 79 | 90 | 91 | )} 92 | 93 |
94 | 95 | {audioLevelText}: 96 | 97 | 102 |
103 |
104 | ); 105 | } 106 | -------------------------------------------------------------------------------- /src/AudioDeviceTestWidget/AudioDeviceTestWidget.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Button, Typography } from '@material-ui/core'; 3 | import { mount, shallow } from 'enzyme'; 4 | 5 | import Alert from '../common/Alert/Alert'; 6 | import AudioDevice from './AudioDevice/AudioDevice'; 7 | import AudioDeviceTestWidget from './AudioDeviceTestWidget'; 8 | import useTestRunner from './useTestRunner/useTestRunner'; 9 | 10 | jest.mock('./AudioDevice/AudioDevice'); 11 | jest.mock('./useTestRunner/useTestRunner'); 12 | const mockAudioDevice = AudioDevice as jest.Mock; 13 | const mockUseTestRunner = useTestRunner as jest.Mock; 14 | 15 | describe('the AudioDeviceTestWidget component', () => { 16 | let hookProps: any; 17 | 18 | beforeEach(() => { 19 | hookProps = { 20 | error: '', 21 | inputLevel: 0, 22 | isRecording: false, 23 | isAudioInputTestRunning: false, 24 | isAudioOutputTestRunning: false, 25 | outputLevel: 0, 26 | playAudio: jest.fn(), 27 | playbackURI: '', 28 | readAudioInput: jest.fn(), 29 | testEnded: false, 30 | }; 31 | mockUseTestRunner.mockImplementation(() => hookProps); 32 | mockAudioDevice.mockImplementation(() => null); 33 | }); 34 | 35 | it('should render correct components on load', () => { 36 | const wrapper = shallow(); 37 | expect(wrapper.find(AudioDevice).length).toEqual(2); 38 | 39 | const outputDevice = wrapper.find(AudioDevice).at(0); 40 | const inputDevice = wrapper.find(AudioDevice).at(1); 41 | const recordBtn = wrapper.find(Button).at(0); 42 | const playBtn = wrapper.find(Button).at(1); 43 | 44 | expect(wrapper.find(Alert).exists()).toBeFalsy(); 45 | expect(outputDevice.prop('disabled')).toBeFalsy(); 46 | expect(inputDevice.prop('disabled')).toBeFalsy(); 47 | expect(recordBtn.prop('disabled')).toBeFalsy(); 48 | expect(playBtn.prop('disabled')).toBeTruthy(); 49 | expect(recordBtn.text()).toEqual('Record'); 50 | expect(playBtn.text()).toEqual('Play'); 51 | }); 52 | 53 | describe('passive testing', () => { 54 | it('should start passive testing by default', () => { 55 | mount(); 56 | expect(hookProps.readAudioInput).toHaveBeenCalledWith({ deviceId: '' }); 57 | }); 58 | 59 | [ 60 | { 61 | shouldBeCalled: true, 62 | props: { error: '', isRecording: false, isAudioInputTestRunning: false }, 63 | }, 64 | { 65 | shouldBeCalled: false, 66 | props: { error: '', isRecording: true, isAudioInputTestRunning: false }, 67 | }, 68 | { 69 | shouldBeCalled: false, 70 | props: { error: '', isRecording: false, isAudioInputTestRunning: true }, 71 | }, 72 | { 73 | shouldBeCalled: false, 74 | props: { error: '', isRecording: true, isAudioInputTestRunning: true }, 75 | }, 76 | { 77 | shouldBeCalled: false, 78 | props: { error: 'foo', isRecording: false, isAudioInputTestRunning: false }, 79 | }, 80 | { 81 | shouldBeCalled: false, 82 | props: { error: 'foo', isRecording: true, isAudioInputTestRunning: false }, 83 | }, 84 | { 85 | shouldBeCalled: false, 86 | props: { error: 'foo', isRecording: false, isAudioInputTestRunning: true }, 87 | }, 88 | { 89 | shouldBeCalled: false, 90 | props: { error: 'foo', isRecording: true, isAudioInputTestRunning: true }, 91 | }, 92 | ].forEach(({ shouldBeCalled, props }) => { 93 | it(`should${shouldBeCalled ? ' ' : ' not '}call readAudioInput when props are ${JSON.stringify(props)}`, () => { 94 | hookProps = { ...hookProps, ...props }; 95 | mount(); 96 | 97 | if (shouldBeCalled) { 98 | expect(hookProps.readAudioInput).toHaveBeenCalledWith({ deviceId: '' }); 99 | } else { 100 | expect(hookProps.readAudioInput).not.toHaveBeenCalledWith({ deviceId: '' }); 101 | } 102 | }); 103 | }); 104 | }); 105 | 106 | describe('button clicks', () => { 107 | beforeEach(() => { 108 | hookProps = { ...hookProps, isAudioInputTestRunning: true, playbackURI: 'foo' }; 109 | }); 110 | 111 | it('should start recording when record is clicked', () => { 112 | const wrapper = mount(); 113 | const recordBtn = wrapper.find(Button).at(0); 114 | recordBtn.simulate('click'); 115 | expect(hookProps.readAudioInput).toHaveBeenCalledWith({ deviceId: '', enableRecording: true }); 116 | }); 117 | 118 | it('should play recorded click when play is clicked', () => { 119 | const wrapper = mount(); 120 | const playBtn = wrapper.find(Button).at(1); 121 | playBtn.simulate('click'); 122 | expect(hookProps.playAudio).toHaveBeenCalledWith({ deviceId: '', testURI: 'foo' }); 123 | }); 124 | }); 125 | 126 | describe('button labels', () => { 127 | it('should set record button label to Record', () => { 128 | const wrapper = shallow(); 129 | expect(wrapper.find(Button).at(0).text()).toEqual('Record'); 130 | }); 131 | 132 | it('should set record button label to Recording...', () => { 133 | hookProps = { ...hookProps, isRecording: true }; 134 | const wrapper = shallow(); 135 | expect(wrapper.find(Button).at(0).text()).toEqual('Recording...'); 136 | }); 137 | 138 | it('should set play button label to Play', () => { 139 | const wrapper = shallow(); 140 | expect(wrapper.find(Button).at(1).text()).toEqual('Play'); 141 | }); 142 | 143 | it('should set play button label to Playing...', () => { 144 | hookProps = { ...hookProps, isAudioOutputTestRunning: true }; 145 | const wrapper = shallow(); 146 | expect(wrapper.find(Button).at(1).text()).toEqual('Playing...'); 147 | }); 148 | }); 149 | 150 | describe('audio levels', () => { 151 | it('should pass output levels to AudioDevice', () => { 152 | hookProps = { ...hookProps, outputLevel: 32 }; 153 | const wrapper = shallow(); 154 | expect(wrapper.find(AudioDevice).at(0).props().level).toEqual(32); 155 | }); 156 | 157 | it('should pass input levels to AudioDevice', () => { 158 | hookProps = { ...hookProps, inputLevel: 64 }; 159 | const wrapper = shallow(); 160 | expect(wrapper.find(AudioDevice).at(1).props().level).toEqual(64); 161 | }); 162 | }); 163 | 164 | describe('alerts', () => { 165 | it('should render error', () => { 166 | hookProps = { ...hookProps, error: 'foo' }; 167 | const wrapper = shallow(); 168 | const alert = wrapper.find(Alert).at(0); 169 | 170 | expect(wrapper.find(Alert).length).toEqual(1); 171 | expect(alert.props().variant).toEqual('error'); 172 | expect(alert.find(Typography).text()).toEqual('foo'); 173 | }); 174 | 175 | it('should render warning', () => { 176 | hookProps = { ...hookProps, warning: 'foo' }; 177 | const wrapper = shallow(); 178 | const alert = wrapper.find(Alert).at(0); 179 | 180 | expect(wrapper.find(Alert).length).toEqual(1); 181 | expect(alert.props().variant).toEqual('warning'); 182 | expect(alert.find(Typography).text()).toEqual('foo'); 183 | }); 184 | 185 | it('should render success if there is no error', () => { 186 | hookProps = { ...hookProps, error: '', testEnded: true }; 187 | const wrapper = shallow(); 188 | const alert = wrapper.find(Alert).at(0); 189 | 190 | expect(wrapper.find(Alert).length).toEqual(1); 191 | expect(alert.props().variant).toEqual('success'); 192 | expect(alert.find(Typography).text()).toEqual('No issues detected'); 193 | }); 194 | 195 | it('should render success if there is no warning', () => { 196 | hookProps = { ...hookProps, warning: '', testEnded: true }; 197 | const wrapper = shallow(); 198 | const alert = wrapper.find(Alert).at(0); 199 | 200 | expect(wrapper.find(Alert).length).toEqual(1); 201 | expect(alert.props().variant).toEqual('success'); 202 | expect(alert.find(Typography).text()).toEqual('No issues detected'); 203 | }); 204 | 205 | it('should not render success if there is an error', () => { 206 | hookProps = { ...hookProps, error: 'foo', testEnded: true }; 207 | const wrapper = shallow(); 208 | const alert = wrapper.find(Alert).at(0); 209 | 210 | expect(wrapper.find(Alert).length).toEqual(1); 211 | expect(alert.props().variant).toEqual('error'); 212 | expect(alert.find(Typography).text()).toEqual('foo'); 213 | }); 214 | 215 | it('should not render success if there is a warning', () => { 216 | hookProps = { ...hookProps, warning: 'foo', testEnded: true }; 217 | const wrapper = shallow(); 218 | const alert = wrapper.find(Alert).at(0); 219 | 220 | expect(wrapper.find(Alert).length).toEqual(1); 221 | expect(alert.props().variant).toEqual('warning'); 222 | expect(alert.find(Typography).text()).toEqual('foo'); 223 | }); 224 | 225 | it('should disable all controls if there is an error', () => { 226 | hookProps = { ...hookProps, error: 'foo', testEnded: true }; 227 | const wrapper = shallow(); 228 | expect(wrapper.find(AudioDevice).length).toEqual(2); 229 | 230 | const outputDevice = wrapper.find(AudioDevice).at(0); 231 | const inputDevice = wrapper.find(AudioDevice).at(1); 232 | const recordBtn = wrapper.find(Button).at(0); 233 | const playBtn = wrapper.find(Button).at(1); 234 | 235 | expect(outputDevice.prop('disabled')).toBeTruthy(); 236 | expect(inputDevice.prop('disabled')).toBeTruthy(); 237 | expect(recordBtn.prop('disabled')).toBeTruthy(); 238 | expect(playBtn.prop('disabled')).toBeTruthy(); 239 | }); 240 | 241 | it('should not disable all controls if there is a warning', () => { 242 | hookProps = { ...hookProps, warning: 'foo', testEnded: true, playbackURI: 'bar' }; 243 | const wrapper = shallow(); 244 | expect(wrapper.find(AudioDevice).length).toEqual(2); 245 | 246 | const outputDevice = wrapper.find(AudioDevice).at(0); 247 | const inputDevice = wrapper.find(AudioDevice).at(1); 248 | const recordBtn = wrapper.find(Button).at(0); 249 | const playBtn = wrapper.find(Button).at(1); 250 | 251 | expect(outputDevice.prop('disabled')).toBeFalsy(); 252 | expect(inputDevice.prop('disabled')).toBeFalsy(); 253 | expect(recordBtn.prop('disabled')).toBeFalsy(); 254 | expect(playBtn.prop('disabled')).toBeFalsy(); 255 | }); 256 | }); 257 | }); 258 | -------------------------------------------------------------------------------- /src/AudioDeviceTestWidget/AudioDeviceTestWidget.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from 'react'; 2 | import clsx from 'clsx'; 3 | import { Button, Divider, Theme, Typography } from '@material-ui/core'; 4 | import { makeStyles } from '@material-ui/core/styles'; 5 | import RecordIcon from '@material-ui/icons/FiberManualRecord'; 6 | import PlayIcon from '@material-ui/icons/PlayArrow'; 7 | 8 | import Alert from '../common/Alert/Alert'; 9 | import AudioDevice from './AudioDevice/AudioDevice'; 10 | import useTestRunner from './useTestRunner/useTestRunner'; 11 | 12 | const useStyles = makeStyles((theme: Theme) => ({ 13 | button: { 14 | backgroundColor: theme.palette.secondary.main, 15 | color: '#fff', 16 | marginRight: '1em', 17 | '&:hover': { 18 | backgroundColor: theme.palette.secondary.dark, 19 | }, 20 | }, 21 | icon: { 22 | marginRight: '0.3em', 23 | }, 24 | deviceContainer: { 25 | display: 'flex', 26 | justifyContent: 'space-between', 27 | flexWrap: 'wrap', 28 | marginBottom: '-30px', 29 | }, 30 | busy: { 31 | backgroundColor: `${theme.palette.error.dark} !important`, 32 | color: '#fff !important', 33 | }, 34 | })); 35 | 36 | export default function AudioDeviceTestWidget() { 37 | const classes = useStyles(); 38 | const [inputDeviceId, setInputDeviceId] = useState(''); 39 | const [outputDeviceId, setOutputDeviceId] = useState(''); 40 | const previousInputDeviceIdRef = useRef(''); 41 | 42 | const { 43 | error, 44 | warning, 45 | inputLevel, 46 | isRecording, 47 | isAudioInputTestRunning, 48 | isAudioOutputTestRunning, 49 | outputLevel, 50 | playAudio, 51 | playbackURI, 52 | readAudioInput, 53 | testEnded, 54 | } = useTestRunner(); 55 | 56 | const disableAll = isRecording || isAudioOutputTestRunning || !!error; 57 | 58 | const handleRecordClick = () => { 59 | readAudioInput({ deviceId: inputDeviceId, enableRecording: true }); 60 | }; 61 | 62 | const handlePlayClick = () => { 63 | playAudio({ deviceId: outputDeviceId, testURI: playbackURI }); 64 | }; 65 | 66 | useEffect(() => { 67 | const newDeviceSelected = previousInputDeviceIdRef.current !== inputDeviceId; 68 | previousInputDeviceIdRef.current = inputDeviceId; 69 | 70 | // Restarts the test to continuously capture audio input 71 | if (!error && (newDeviceSelected || (!isRecording && !isAudioInputTestRunning))) { 72 | readAudioInput({ deviceId: inputDeviceId }); 73 | } 74 | }, [error, inputDeviceId, isRecording, isAudioInputTestRunning, readAudioInput]); 75 | 76 | return ( 77 |
78 | 79 | Audio Device Tests 80 | 81 | 82 | {!!error && ( 83 | 84 | {error} 85 | 86 | )} 87 | 88 | {!!warning && ( 89 | 90 | {warning} 91 | 92 | )} 93 | 94 | 103 | 104 | 113 | 114 | 115 | 116 | {testEnded && !error && !warning && ( 117 | 118 | No issues detected 119 | 120 | )} 121 | 122 |
123 | 124 | 125 |
126 |
127 | ); 128 | } 129 | -------------------------------------------------------------------------------- /src/AudioDeviceTestWidget/useDevices/useDevices.test.ts: -------------------------------------------------------------------------------- 1 | import { act, renderHook } from '@testing-library/react-hooks'; 2 | import { useDevices } from './useDevices'; 3 | 4 | describe('the useDevices hook', () => { 5 | const mediaInfoProps = { groupId: 'foo', toJSON: () => {} }; 6 | const root = global as any; 7 | let mockGetUserMedia: () => Promise; 8 | let mockDevices: any; 9 | let originalMediaDevices: any; 10 | 11 | beforeEach(() => { 12 | mockDevices = [ 13 | { 14 | deviceId: 'input1', 15 | label: '', 16 | kind: 'audioinput', 17 | ...mediaInfoProps, 18 | }, 19 | { 20 | deviceId: 'output1', 21 | label: '', 22 | kind: 'audiooutput', 23 | ...mediaInfoProps, 24 | }, 25 | ]; 26 | 27 | mockGetUserMedia = () => { 28 | mockDevices[0].label = 'deviceinput1'; 29 | mockDevices[1].label = 'deviceoutput1'; 30 | return Promise.resolve(); 31 | }; 32 | 33 | originalMediaDevices = root.navigator.mediaDevices; 34 | root.navigator.mediaDevices = { 35 | enumerateDevices: () => Promise.resolve(mockDevices), 36 | getUserMedia: mockGetUserMedia, 37 | addEventListener: jest.fn(), 38 | removeEventListener: jest.fn(), 39 | } as any; 40 | }); 41 | 42 | afterAll(() => { 43 | root.navigator.mediaDevices = originalMediaDevices; 44 | }); 45 | 46 | it('should call getUserMedia if labels do not exist', async () => { 47 | const { result, waitForNextUpdate } = renderHook(useDevices); 48 | await waitForNextUpdate(); 49 | expect(result.current.length).toBeTruthy(); 50 | expect(result.current[0].label).toEqual('deviceinput1'); 51 | expect(result.current[1].label).toEqual('deviceoutput1'); 52 | }); 53 | 54 | it('should not call getUserMedia if audio labels exist', async () => { 55 | mockDevices[0].label = 'foo'; 56 | mockDevices[1].label = 'bar'; 57 | mockDevices.push({ 58 | deviceId: 'video1', 59 | label: '', 60 | kind: 'videoinput', 61 | ...mediaInfoProps, 62 | }); 63 | 64 | const { result, waitForNextUpdate } = renderHook(useDevices); 65 | await waitForNextUpdate(); 66 | expect(result.current.length).toEqual(3); 67 | expect(result.current[0].label).toEqual('foo'); 68 | expect(result.current[1].label).toEqual('bar'); 69 | }); 70 | 71 | it('should respond to "devicechange" events', async () => { 72 | const { result, waitForNextUpdate } = renderHook(useDevices); 73 | await waitForNextUpdate(); 74 | expect(root.navigator.mediaDevices.addEventListener).toHaveBeenCalledWith('devicechange', expect.any(Function)); 75 | act(() => { 76 | root.navigator.mediaDevices.enumerateDevices = () => 77 | Promise.resolve([ 78 | { 79 | deviceId: 'inputFoo', 80 | label: 'labelBar', 81 | kind: 'audioinput', 82 | ...mediaInfoProps, 83 | }, 84 | ]); 85 | root.navigator.mediaDevices.addEventListener.mock.calls[0][1](); 86 | }); 87 | await waitForNextUpdate(); 88 | expect(result.current).toEqual([ 89 | { 90 | deviceId: 'inputFoo', 91 | label: 'labelBar', 92 | kind: 'audioinput', 93 | ...mediaInfoProps, 94 | }, 95 | ]); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /src/AudioDeviceTestWidget/useDevices/useDevices.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | export function useDevices() { 4 | const [devices, setDevices] = useState([]); 5 | 6 | useEffect(() => { 7 | const getDevices = () => 8 | navigator.mediaDevices 9 | .enumerateDevices() 10 | .then((mediaDevices) => 11 | mediaDevices 12 | .filter((device) => device.kind === 'audioinput' || device.kind === 'audiooutput') 13 | .every((device) => !(device.deviceId && device.label)) 14 | ) 15 | .then((shouldAskForMediaPermissions) => { 16 | if (shouldAskForMediaPermissions) { 17 | return navigator.mediaDevices.getUserMedia({ audio: true }); 18 | } 19 | }) 20 | .then(() => navigator.mediaDevices.enumerateDevices().then((mediaDevices) => setDevices(mediaDevices))); 21 | 22 | navigator.mediaDevices.addEventListener('devicechange', getDevices); 23 | getDevices(); 24 | 25 | return () => { 26 | navigator.mediaDevices.removeEventListener('devicechange', getDevices); 27 | }; 28 | }, []); 29 | 30 | return devices; 31 | } 32 | -------------------------------------------------------------------------------- /src/AudioDeviceTestWidget/useTestRunner/useTestRunner.test.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import { act, HookResult, renderHook } from '@testing-library/react-hooks'; 3 | import useTestRunner from './useTestRunner'; 4 | 5 | class MockAudioInputTest extends EventEmitter { 6 | stop = jest.fn(); 7 | } 8 | class MockAudioOutputTest extends EventEmitter { 9 | stop = jest.fn(); 10 | } 11 | 12 | let mockAudioInputTest: MockAudioInputTest; 13 | let mockAudioOutputTest: MockAudioOutputTest; 14 | 15 | jest.mock('@twilio/rtc-diagnostics', () => ({ 16 | testAudioInputDevice: jest.fn(() => { 17 | mockAudioInputTest = new MockAudioInputTest(); 18 | return mockAudioInputTest; 19 | }), 20 | testAudioOutputDevice: jest.fn(() => { 21 | mockAudioOutputTest = new MockAudioOutputTest(); 22 | return mockAudioOutputTest; 23 | }), 24 | AudioInputTest: { 25 | Events: { 26 | End: 'end', 27 | Error: 'error', 28 | Volume: 'volume', 29 | Warning: 'warning', 30 | WarningCleared: 'warning-cleared', 31 | }, 32 | }, 33 | AudioOutputTest: { 34 | Events: { 35 | End: 'end', 36 | Error: 'error', 37 | Volume: 'volume', 38 | }, 39 | }, 40 | })); 41 | 42 | jest.mock('../../utils', () => ({ 43 | getAudioLevelPercentage: (value: number) => value, 44 | 45 | // Just returns the first value for easier mocking 46 | getStandardDeviation: (values: number[]) => values[0], 47 | })); 48 | 49 | describe('the useTestRunner hook', () => { 50 | describe('output test', () => { 51 | it('should have correct states after starting the test', () => { 52 | const { result } = renderHook(useTestRunner); 53 | expect(result.current.isAudioOutputTestRunning).toBeFalsy(); 54 | act(() => result.current.playAudio({})); 55 | expect(result.current.isAudioOutputTestRunning).toBeTruthy(); 56 | }); 57 | 58 | it('should update audio levels', () => { 59 | const { result } = renderHook(useTestRunner); 60 | expect(result.current.outputLevel).toEqual(0); 61 | act(() => { 62 | result.current.playAudio({}); 63 | mockAudioOutputTest.emit('volume', 20); 64 | }); 65 | 66 | expect(result.current.outputLevel).toEqual(20); 67 | }); 68 | 69 | describe('when error happens', () => { 70 | it('should use default message', () => { 71 | const { result } = renderHook(useTestRunner); 72 | expect(result.current.error).toEqual(''); 73 | act(() => { 74 | result.current.playAudio({}); 75 | mockAudioOutputTest.emit('error'); 76 | }); 77 | expect(result.current.error).toEqual('An unknown error has occurred'); 78 | }); 79 | 80 | it('should use domError message', () => { 81 | const { result } = renderHook(useTestRunner); 82 | expect(result.current.error).toEqual(''); 83 | act(() => { 84 | result.current.playAudio({}); 85 | mockAudioOutputTest.emit('error', { 86 | domError: { 87 | toString: () => 'dom error', 88 | }, 89 | }); 90 | }); 91 | expect(result.current.error).toEqual('dom error'); 92 | }); 93 | 94 | it('should use error message if dom error is not available', () => { 95 | const { result } = renderHook(useTestRunner); 96 | expect(result.current.error).toEqual(''); 97 | act(() => { 98 | result.current.playAudio({}); 99 | mockAudioOutputTest.emit('error', { 100 | message: 'error message', 101 | }); 102 | }); 103 | expect(result.current.error).toEqual('error message'); 104 | }); 105 | }); 106 | 107 | describe('when test ends', () => { 108 | let result: HookResult; 109 | 110 | beforeEach(() => { 111 | result = renderHook(useTestRunner).result; 112 | act(() => result.current.playAudio({})); 113 | }); 114 | 115 | it('should reset states', () => { 116 | expect(result.current.isAudioOutputTestRunning).toBeTruthy(); 117 | expect(result.current.testEnded).toBeFalsy(); 118 | act(() => { 119 | mockAudioOutputTest.emit('volume', 20); 120 | mockAudioOutputTest.emit('end', { values: [] }); 121 | }); 122 | expect(result.current.isAudioOutputTestRunning).toBeFalsy(); 123 | expect(result.current.testEnded).toBeTruthy(); 124 | expect(result.current.outputLevel).toEqual(0); 125 | }); 126 | 127 | it('should set error when no audio detected', () => { 128 | act(() => { 129 | mockAudioOutputTest.emit('end', { values: [0, 0, 0, 0] }); 130 | }); 131 | expect(result.current.error).toEqual('No audio detected'); 132 | expect(result.current.warning).toEqual(''); 133 | }); 134 | 135 | it('should set warning when low audio levels detected', () => { 136 | act(() => { 137 | mockAudioOutputTest.emit('end', { values: [9, 2, 4, 4] }); 138 | }); 139 | expect(result.current.warning).toEqual('Low audio levels detected'); 140 | expect(result.current.error).toEqual(''); 141 | }); 142 | 143 | it('should not set error and warning when audio levels are normal', () => { 144 | act(() => { 145 | mockAudioOutputTest.emit('end', { values: [10, 11, 11, 10] }); 146 | }); 147 | expect(result.current.error).toEqual(''); 148 | expect(result.current.warning).toEqual(''); 149 | }); 150 | }); 151 | }); 152 | 153 | describe('input test', () => { 154 | it('should stop existing passive test', () => { 155 | const { result } = renderHook(useTestRunner); 156 | act(() => result.current.readAudioInput({})); 157 | expect(mockAudioInputTest.stop).not.toHaveBeenCalled(); 158 | 159 | const previousMockAudioInputTest = mockAudioInputTest; 160 | act(() => result.current.readAudioInput({})); 161 | expect(previousMockAudioInputTest.stop).toHaveBeenCalled(); 162 | }); 163 | 164 | it('should have correct states after starting a passive test', () => { 165 | const { result } = renderHook(useTestRunner); 166 | expect(result.current.isRecording).toBeFalsy(); 167 | expect(result.current.isAudioInputTestRunning).toBeFalsy(); 168 | expect(result.current.testEnded).toBeFalsy(); 169 | 170 | act(() => result.current.readAudioInput({})); 171 | 172 | expect(result.current.isRecording).toBeFalsy(); 173 | expect(result.current.isAudioInputTestRunning).toBeTruthy(); 174 | expect(result.current.testEnded).toBeFalsy(); 175 | }); 176 | 177 | it('should have correct states after starting a recording test', () => { 178 | const { result } = renderHook(useTestRunner); 179 | act(() => { 180 | result.current.playAudio({}); 181 | mockAudioOutputTest.emit('end', { values: [] }); 182 | }); 183 | expect(result.current.isRecording).toBeFalsy(); 184 | expect(result.current.testEnded).toBeTruthy(); 185 | 186 | act(() => result.current.readAudioInput({ enableRecording: true })); 187 | expect(result.current.isRecording).toBeTruthy(); 188 | expect(result.current.testEnded).toBeFalsy(); 189 | }); 190 | 191 | it('should update audio levels', () => { 192 | const { result } = renderHook(useTestRunner); 193 | expect(result.current.inputLevel).toEqual(0); 194 | act(() => { 195 | result.current.readAudioInput({}); 196 | mockAudioInputTest.emit('volume', 20); 197 | }); 198 | 199 | expect(result.current.inputLevel).toEqual(20); 200 | }); 201 | 202 | describe('when error happens', () => { 203 | it('should use default message', () => { 204 | const { result } = renderHook(useTestRunner); 205 | expect(result.current.error).toEqual(''); 206 | act(() => { 207 | result.current.readAudioInput({}); 208 | mockAudioInputTest.emit('error'); 209 | }); 210 | expect(result.current.error).toEqual('An unknown error has occurred'); 211 | }); 212 | 213 | it('should use domError message', () => { 214 | const { result } = renderHook(useTestRunner); 215 | expect(result.current.error).toEqual(''); 216 | act(() => { 217 | result.current.readAudioInput({}); 218 | mockAudioInputTest.emit('error', { 219 | domError: { 220 | toString: () => 'dom error', 221 | }, 222 | }); 223 | }); 224 | expect(result.current.error).toEqual('dom error'); 225 | }); 226 | 227 | it('should use error message if dom error is not available', () => { 228 | const { result } = renderHook(useTestRunner); 229 | expect(result.current.error).toEqual(''); 230 | act(() => { 231 | result.current.readAudioInput({}); 232 | mockAudioInputTest.emit('error', { 233 | message: 'error message', 234 | }); 235 | }); 236 | expect(result.current.error).toEqual('error message'); 237 | }); 238 | }); 239 | 240 | describe('when test ends', () => { 241 | let result: HookResult; 242 | 243 | beforeEach(() => { 244 | result = renderHook(useTestRunner).result; 245 | act(() => result.current.readAudioInput({ enableRecording: true })); 246 | }); 247 | 248 | it('should reset states', () => { 249 | expect(result.current.isRecording).toBeTruthy(); 250 | expect(result.current.isAudioInputTestRunning).toBeTruthy(); 251 | act(() => { 252 | mockAudioInputTest.emit('end', {}); 253 | }); 254 | expect(result.current.isRecording).toBeFalsy(); 255 | expect(result.current.isAudioInputTestRunning).toBeFalsy(); 256 | }); 257 | 258 | it('should not set playback URI if not available', () => { 259 | expect(result.current.playbackURI).toEqual(''); 260 | act(() => { 261 | mockAudioInputTest.emit('end', {}); 262 | }); 263 | expect(result.current.playbackURI).toEqual(''); 264 | }); 265 | 266 | it('should set playback URI if available', () => { 267 | expect(result.current.playbackURI).toEqual(''); 268 | act(() => { 269 | mockAudioInputTest.emit('end', { recordingUrl: 'foo' }); 270 | }); 271 | expect(result.current.playbackURI).toEqual('foo'); 272 | }); 273 | }); 274 | }); 275 | }); 276 | -------------------------------------------------------------------------------- /src/AudioDeviceTestWidget/useTestRunner/useTestRunner.ts: -------------------------------------------------------------------------------- 1 | import { useState, useCallback } from 'react'; 2 | import { getLogger } from 'loglevel'; 3 | import { 4 | AudioInputTest, 5 | AudioOutputTest, 6 | DiagnosticError, 7 | testAudioInputDevice, 8 | testAudioOutputDevice, 9 | WarningName, 10 | } from '@twilio/rtc-diagnostics'; 11 | 12 | import { getAudioLevelPercentage, getStandardDeviation } from '../../utils'; 13 | import { 14 | APP_NAME, 15 | AUDIO_LEVEL_STANDARD_DEVIATION_THRESHOLD, 16 | INPUT_TEST_DURATION, 17 | RECORD_DURATION, 18 | } from '../../constants'; 19 | 20 | const log = getLogger(APP_NAME); 21 | let audioInputTest: AudioInputTest; 22 | 23 | const getErrorMessage = (error: DiagnosticError) => { 24 | let message = 'An unknown error has occurred'; 25 | if (error) { 26 | message = error.domError ? error.domError.toString() : error.message; 27 | } 28 | return message; 29 | }; 30 | 31 | export default function useTestRunner() { 32 | const [isRecording, setIsRecording] = useState(false); 33 | const [isAudioInputTestRunning, setIsAudioInputTestRunning] = useState(false); 34 | const [isAudioOutputTestRunning, setIAudioOutputTestRunning] = useState(false); 35 | const [inputLevel, setInputLevel] = useState(0); 36 | const [outputLevel, setOutputLevel] = useState(0); 37 | const [playbackURI, setPlaybackURI] = useState(''); 38 | const [error, setError] = useState(''); 39 | const [warning, setWarning] = useState(''); 40 | const [testEnded, setTestEnded] = useState(false); 41 | 42 | const playAudio = useCallback((options: AudioOutputTest.Options) => { 43 | log.debug('AudioOutputTest running'); 44 | 45 | options = { doLoop: false, ...options }; 46 | const audioOutputTest = testAudioOutputDevice(options); 47 | setIAudioOutputTestRunning(true); 48 | setTestEnded(false); 49 | setWarning(''); 50 | 51 | audioOutputTest.on(AudioOutputTest.Events.Volume, (value: number) => { 52 | setOutputLevel(getAudioLevelPercentage(value)); 53 | }); 54 | 55 | audioOutputTest.on(AudioOutputTest.Events.End, (report: AudioOutputTest.Report) => { 56 | setIAudioOutputTestRunning(false); 57 | setTestEnded(true); 58 | setOutputLevel(0); 59 | 60 | const stdDev = getStandardDeviation(report.values); 61 | if (stdDev === 0) { 62 | setError('No audio detected'); 63 | } else if (stdDev < AUDIO_LEVEL_STANDARD_DEVIATION_THRESHOLD) { 64 | setWarning('Low audio levels detected'); 65 | } 66 | 67 | log.debug('AudioOutputTest ended', report); 68 | }); 69 | 70 | audioOutputTest.on(AudioOutputTest.Events.Error, (diagnosticError: DiagnosticError) => { 71 | log.debug('error', diagnosticError); 72 | setError(getErrorMessage(diagnosticError)); 73 | }); 74 | }, []); 75 | 76 | const readAudioInput = useCallback( 77 | (options: AudioInputTest.Options) => { 78 | if (audioInputTest) { 79 | audioInputTest.stop(); 80 | } 81 | 82 | log.debug('AudioInputTest running'); 83 | const duration = options.enableRecording ? RECORD_DURATION : INPUT_TEST_DURATION; 84 | options = { duration, ...options }; 85 | audioInputTest = testAudioInputDevice(options); 86 | 87 | setIsAudioInputTestRunning(true); 88 | if (options.enableRecording) { 89 | log.debug('Recording audio'); 90 | setTestEnded(false); 91 | setIsRecording(true); 92 | setWarning(''); 93 | } 94 | 95 | audioInputTest.on(AudioInputTest.Events.Volume, (value: number) => { 96 | setInputLevel(getAudioLevelPercentage(value)); 97 | }); 98 | 99 | audioInputTest.on(AudioInputTest.Events.End, (report: AudioInputTest.Report) => { 100 | if (playbackURI && report.recordingUrl) { 101 | URL.revokeObjectURL(playbackURI); 102 | } 103 | 104 | if (report.recordingUrl) { 105 | setPlaybackURI(report.recordingUrl); 106 | } 107 | 108 | setIsRecording(false); 109 | setIsAudioInputTestRunning(false); 110 | log.debug('AudioInputTest ended', report); 111 | }); 112 | 113 | audioInputTest.on(AudioInputTest.Events.Error, (diagnosticError: DiagnosticError) => { 114 | log.debug('error', diagnosticError); 115 | setError(getErrorMessage(diagnosticError)); 116 | }); 117 | audioInputTest.on(AudioInputTest.Events.Warning, (name: WarningName) => { 118 | log.debug('warning', name); 119 | }); 120 | audioInputTest.on(AudioInputTest.Events.WarningCleared, (name: WarningName) => { 121 | log.debug('warning-cleared', name); 122 | }); 123 | }, 124 | [playbackURI] 125 | ); 126 | 127 | return { 128 | error, 129 | warning, 130 | inputLevel, 131 | isRecording, 132 | isAudioInputTestRunning, 133 | isAudioOutputTestRunning, 134 | outputLevel, 135 | playAudio, 136 | playbackURI, 137 | readAudioInput, 138 | testEnded, 139 | }; 140 | } 141 | -------------------------------------------------------------------------------- /src/BrowserCompatibilityWidget/BrowserCompatibilityWidget.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Alert from '../common/Alert/Alert'; 3 | import { Link as LinkImpl, Typography } from '@material-ui/core'; 4 | import { styled } from '@material-ui/core'; 5 | 6 | const Link = styled(LinkImpl)({ 7 | fontWeight: 600, 8 | textDecoration: 'underline', 9 | }); 10 | 11 | export default function BrowserCompatibilityWidget() { 12 | return ( 13 | 14 | 15 | Browser not suported. Please open this application in one of the{' '} 16 | 21 | supported browsers 22 | 23 | . 24 | 25 | 26 | If you are using a supported browser, please ensure that this app is served over a{' '} 27 | 32 | secure context 33 | 34 | . 35 | 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/CopyResultsWidget/CopyResultsWidget.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { act } from 'react-dom/test-utils'; 3 | import { Button, Snackbar } from '@material-ui/core'; 4 | import CopyResultsWidget from './CopyResultsWidget'; 5 | import { mount } from 'enzyme'; 6 | import { TestResults } from '../types'; 7 | 8 | Object.defineProperty(navigator, 'clipboard', { value: { writeText: jest.fn(() => Promise.resolve('ok')) } }); 9 | const mockResults = ([{ results: 'test' }] as any) as TestResults[]; 10 | 11 | describe('the CopyResultsWidget', () => { 12 | it('should not render when there are no results', () => { 13 | const wrapper = mount(); 14 | expect(wrapper.find(Button).exists()).toBe(false); 15 | }); 16 | 17 | it('should render a button when results are available', () => { 18 | const wrapper = mount(); 19 | expect(wrapper.find(Button).exists()).toBe(true); 20 | }); 21 | 22 | it('should copy results to the clipboard when clicked and then display a snackbar', async () => { 23 | const wrapper = mount(); 24 | expect(wrapper.find(Snackbar).prop('open')).toBe(false); 25 | 26 | await act(async () => { 27 | wrapper.find(Button).simulate('click'); 28 | }); 29 | 30 | expect(navigator.clipboard.writeText).toHaveBeenCalledWith(`[ 31 | { 32 | "results": "test" 33 | } 34 | ]`); 35 | 36 | wrapper.update(); 37 | expect(wrapper.find(Snackbar).prop('open')).toBe(true); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/CopyResultsWidget/CopyResultsWidget.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Button, Snackbar, makeStyles, Theme } from '@material-ui/core'; 3 | import CopyIcon from '@material-ui/icons/FileCopy'; 4 | import SuccessIcon from '@material-ui/icons/CheckCircle'; 5 | import { TestResults } from '../types'; 6 | 7 | const useStyles = makeStyles((theme: Theme) => ({ 8 | snackBar: { 9 | background: theme.palette.success.main, 10 | }, 11 | successIcon: { 12 | verticalAlign: 'middle', 13 | marginRight: '0.5em', 14 | }, 15 | })); 16 | 17 | interface CopyResultsWidgetProps { 18 | results?: TestResults[]; 19 | } 20 | 21 | export default function CopyResultsWidget({ results }: CopyResultsWidgetProps) { 22 | const [hasCopied, setHasCopied] = useState(false); 23 | const classes = useStyles(); 24 | 25 | if (!results) return null; 26 | 27 | const handleCopyResults = () => { 28 | const text = JSON.stringify(results, null, 2); 29 | navigator.clipboard.writeText(text).then(() => setHasCopied(true)); 30 | }; 31 | 32 | return ( 33 | <> 34 | 38 | 39 | Results Copied to Clipboard 40 | 41 | } 42 | open={hasCopied} 43 | anchorOrigin={{ 44 | vertical: 'bottom', 45 | horizontal: 'right', 46 | }} 47 | autoHideDuration={3000} 48 | onClose={() => setHasCopied(false)} 49 | /> 50 | 54 | 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /src/NetworkTestWidget/EdgeResult/EdgeResult.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { mount } from 'enzyme'; 3 | import { Call } from '@twilio/voice-sdk'; 4 | import EdgeResult from './EdgeResult'; 5 | import ProgressBar from '../../common/ProgressBar/ProgressBar'; 6 | import { Tooltip } from '@material-ui/core'; 7 | 8 | import ResultIcon from '../../ResultWidget/ResultIcon/ResultIcon'; 9 | 10 | const { PCMU, Opus } = Call.Codec; 11 | 12 | const testResult: any = { 13 | errors: {}, 14 | results: { preflight: { warnings: [], samples: [{ bytesReceived: 1000 }] } }, 15 | } as any; 16 | 17 | describe('the EdgeResult component', () => { 18 | describe('child ProgressBar component', () => { 19 | it('should have the correct props with no active test', () => { 20 | const wrapper = mount(); 21 | expect(wrapper.find(ProgressBar).props()).toEqual({ duration: 0, position: 0 }); 22 | }); 23 | 24 | it('should have the correct props when the active test is Preflight', () => { 25 | const wrapper = mount( 26 | 27 | ); 28 | expect(wrapper.find(ProgressBar).props()).toEqual({ duration: 25, position: 62.5 }); 29 | }); 30 | 31 | it('should have the correct props when the active test is Bitrate', () => { 32 | const wrapper = mount( 33 | 34 | ); 35 | expect(wrapper.find(ProgressBar).props()).toEqual({ duration: 15, position: 100 }); 36 | }); 37 | }); 38 | 39 | it('should not render a Tooltip or Progress bar when it is not active and there are no results', () => { 40 | const wrapper = mount(); 41 | expect(wrapper.find(ProgressBar).exists()).toBe(false); 42 | expect(wrapper.find(Tooltip).exists()).toBe(false); 43 | }); 44 | 45 | it('should render a Tooltip when results are present', () => { 46 | const wrapper = mount(); 47 | expect(wrapper.find(Tooltip).exists()).toBe(true); 48 | }); 49 | 50 | it('should render ResultIcon when results are present', () => { 51 | const wrapper = mount(); 52 | expect(wrapper.find(ResultIcon).exists()).toBe(true); 53 | }); 54 | 55 | it('should not render ResultIcon when there are not results', () => { 56 | const wrapper = mount(); 57 | expect(wrapper.find(ResultIcon).exists()).toBe(false); 58 | }); 59 | 60 | [ 61 | { 62 | codecPreferences: [Opus], 63 | label: 'Ashburn (Opus)', 64 | }, 65 | { 66 | codecPreferences: [PCMU], 67 | label: 'Ashburn (PCMU)', 68 | }, 69 | { 70 | codecPreferences: [Opus, PCMU], 71 | label: 'Ashburn (Opus, PCMU)', 72 | }, 73 | { 74 | codecPreferences: [PCMU, Opus], 75 | label: 'Ashburn (PCMU, Opus)', 76 | }, 77 | ].forEach((test) => { 78 | it(`should render label properly if test is not active and codecPreferences is [${test.codecPreferences.join()}]`, () => { 79 | const wrapper = mount(); 80 | expect(wrapper.at(0).text()).toBe(test.label); 81 | }); 82 | 83 | it(`should render label properly if test is active and codecPreferences is [${test.codecPreferences.join()}]`, () => { 84 | const wrapper = mount(); 85 | expect(wrapper.at(0).text()).toBe(test.label); 86 | }); 87 | 88 | it(`should render label properly if there is result and codecPreferences is [${test.codecPreferences.join()}]`, () => { 89 | const wrapper = mount( 90 | 91 | ); 92 | expect(wrapper.at(0).text()).toBe(test.label); 93 | }); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /src/NetworkTestWidget/EdgeResult/EdgeResult.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Call } from '@twilio/voice-sdk'; 3 | import { makeStyles, Theme } from '@material-ui/core/styles'; 4 | import { Typography, Tooltip } from '@material-ui/core'; 5 | import clsx from 'clsx'; 6 | 7 | import InfoIcon from '@material-ui/icons/Info'; 8 | import ProgressBar from '../../common/ProgressBar/ProgressBar'; 9 | import { codecNameMap, edgeNameMap } from '../../utils'; 10 | 11 | import { BITRATE_TEST_DURATION } from '../Tests/Tests'; 12 | import ResultIcon from '../../ResultWidget/ResultIcon/ResultIcon'; 13 | import getTooltipContent from './getTooltipContent'; 14 | import { NetworkTestName, Edge, TestResults } from '../../types'; 15 | 16 | const useStyles = makeStyles((theme: Theme) => ({ 17 | container: { 18 | border: '1px solid #ddd', 19 | borderRadius: '3px', 20 | display: 'flex', 21 | padding: '0.8em', 22 | background: '#eee', 23 | alignItems: 'center', 24 | margin: '1em 0', 25 | justifyContent: 'space-between', 26 | }, 27 | progressContainer: { 28 | flex: 1, 29 | padding: '0 1em', 30 | }, 31 | edgeLabel: { 32 | minWidth: '170px', 33 | width: '15%', 34 | whiteSpace: 'nowrap', 35 | }, 36 | iconContainer: { 37 | width: '15%', 38 | display: 'flex', 39 | justifyContent: 'flex-end', 40 | '& svg': { 41 | margin: '0 0.3em', 42 | }, 43 | }, 44 | pendingTest: { 45 | opacity: 0.5, 46 | }, 47 | })); 48 | 49 | interface EdgeResultProps { 50 | codecPreferences: Call.Codec[]; 51 | edge: Edge; 52 | isActive: boolean; 53 | result?: TestResults; 54 | activeTest?: NetworkTestName; 55 | } 56 | 57 | const progressBarTimings = { 58 | 'Preflight Test': { 59 | position: 62.5, 60 | duration: 25, 61 | }, 62 | 'Bitrate Test': { 63 | position: 100, 64 | duration: BITRATE_TEST_DURATION / 1000, 65 | }, 66 | }; 67 | 68 | export default function EdgeResult(props: EdgeResultProps) { 69 | const { codecPreferences, edge, isActive, result, activeTest } = props; 70 | const classes = useStyles(); 71 | 72 | const progressDuration = activeTest ? progressBarTimings[activeTest].duration : 0; 73 | const progressPosition = activeTest ? progressBarTimings[activeTest].position : 0; 74 | 75 | const codecLabel = codecPreferences.map((codec) => codecNameMap[codec]).join(', '); 76 | 77 | return ( 78 |
79 | {`${edgeNameMap[edge]} (${codecLabel})`} 80 |
81 | {isActive && } 82 |
83 | {result && ( 84 |
85 | 86 | 87 | 88 | 89 |
90 | )} 91 |
92 | ); 93 | } 94 | -------------------------------------------------------------------------------- /src/NetworkTestWidget/EdgeResult/getTooltipContent.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import getTooltipContent from './getTooltipContent'; 3 | import * as rowsObj from '../../ResultWidget/rows'; 4 | import { shallow } from 'enzyme'; 5 | import { TestWarnings } from '../../types'; 6 | 7 | describe('the getTooltipContent function', () => { 8 | describe('with a valid MOS score', () => { 9 | it('should correctly return an array of react components', () => { 10 | // We already have tests for the getValue and getWarning functions for all the rows. 11 | // Here we can mock the rows instead of passing mock data to the real rows. 12 | // As a result, nothing needs to be passed to the getTooltipContent function. 13 | 14 | // @ts-ignore 15 | rowsObj.rows = [ 16 | { 17 | label: 'Expected Audio Quality (MOS)', 18 | getValue: () => 'Excellent (4.24)', 19 | }, 20 | { 21 | getWarning: () => TestWarnings.warn, 22 | tooltipContent: { [TestWarnings.warn]: Test Warning Content }, 23 | }, 24 | ]; 25 | 26 | // @ts-ignore 27 | const result = shallow(
{getTooltipContent({ errors: {} } as any)}
); 28 | expect(result).toMatchInlineSnapshot(` 29 |
30 | 33 | Expected call quality: 34 | Excellent (4.24) 35 | 36 | 37 | Test Warning Content 38 | 39 |
40 | `); 41 | }); 42 | }); 43 | 44 | describe('with no MOS score', () => { 45 | it('should correctly return an array of react components', () => { 46 | // @ts-ignore 47 | rowsObj.rows = [ 48 | { 49 | label: 'Expected Audio Quality (MOS)', 50 | getValue: () => {}, 51 | }, 52 | { 53 | getWarning: () => TestWarnings.error, 54 | tooltipContent: { [TestWarnings.error]: Test Error Content }, 55 | }, 56 | ]; 57 | 58 | // @ts-ignore 59 | const result = shallow(
{getTooltipContent({ errors: {} } as any)}
); 60 | expect(result).toMatchInlineSnapshot(` 61 |
62 | 63 | Test Error Content 64 | 65 |
66 | `); 67 | }); 68 | }); 69 | 70 | it('should render an error when the bitrate test returns an "expired" error', () => { 71 | const result = shallow(
{getTooltipContent({ errors: { bitrate: { message: 'expired' } } } as any)}
); 72 | expect(result).toMatchInlineSnapshot(` 73 |
74 | 77 | App has expired. Please redeploy the app and try again. 78 | 79 |
80 | `); 81 | }); 82 | 83 | it('should render an error when the preflight test returns an "expired" error', () => { 84 | const result = shallow(
{getTooltipContent({ errors: { preflight: { message: 'expired' } } } as any)}
); 85 | expect(result).toMatchInlineSnapshot(` 86 |
87 | 90 | App has expired. Please redeploy the app and try again. 91 | 92 |
93 | `); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /src/NetworkTestWidget/EdgeResult/getTooltipContent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { rows } from '../../ResultWidget/rows'; 3 | import { TestResults } from '../../types'; 4 | import { Typography as TooltipTypography } from '../../ResultWidget/rows/shared'; 5 | 6 | export default function getTooltipContent(result: TestResults): React.ReactElement[] { 7 | if (result.errors.bitrate?.message === 'expired' || result.errors.preflight?.message === 'expired') { 8 | return [ 9 | App has expired. Please redeploy the app and try again., 10 | ]; 11 | } 12 | 13 | const warnings = rows 14 | .map((row) => { 15 | const warning = row.getWarning?.(result); 16 | return warning ? row.tooltipContent?.[warning] : null; 17 | }) 18 | .filter((content) => content !== null) 19 | .map((content, i) => {content}); // Adding keys to suppress dev warning 20 | 21 | const expectedQualityRow = rows.find((row) => row.label === 'Expected Audio Quality (MOS)'); 22 | const expectedQualityValue = expectedQualityRow!.getValue(result); 23 | 24 | if (expectedQualityValue) { 25 | warnings.unshift( 26 | Expected call quality: {expectedQualityValue} 27 | ); 28 | } 29 | 30 | return warnings; 31 | } 32 | -------------------------------------------------------------------------------- /src/NetworkTestWidget/NetworkTestWidget.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Button } from '@material-ui/core'; 3 | import NetworkTestWidget from './NetworkTestWidget'; 4 | import EdgeResult from './EdgeResult/EdgeResult'; 5 | import { shallow } from 'enzyme'; 6 | import useTestRunner from './useTestRunner/useTestRunner'; 7 | import Alert from '../common/Alert/Alert'; 8 | import { setImmediate } from 'timers'; 9 | 10 | jest.mock('./useTestRunner/useTestRunner'); 11 | const mockUseTestRunner = useTestRunner as jest.Mock; 12 | 13 | jest.mock('../constants', () => ({ 14 | APP_NAME: 'foo', 15 | DEFAULT_CODEC_PREFERENCES: ['opus'], 16 | DEFAULT_EDGES: ['ashburn', 'dublin', 'roaming'], 17 | LOG_LEVEL: 'debug', 18 | })); 19 | 20 | describe('the NetworkTestWidget component', () => { 21 | it('should render EdgeResult components when there are no results', () => { 22 | mockUseTestRunner.mockImplementation(() => ({ 23 | isRunning: false, 24 | results: [], 25 | activeEdge: undefined, 26 | activeTest: undefined, 27 | runTests: jest.fn(), 28 | })); 29 | 30 | const wrapper = shallow( 31 | {}) as any} 33 | getVoiceToken={(() => {}) as any} 34 | onComplete={() => {}} 35 | /> 36 | ); 37 | 38 | expect(wrapper.find(EdgeResult).exists()).toBe(true); 39 | expect(wrapper.find(Button).find({ disabled: false }).length).toBe(2); 40 | }); 41 | 42 | it('should correctly render EdgeResult components while tests are active', () => { 43 | mockUseTestRunner.mockImplementation(() => ({ 44 | isRunning: true, 45 | results: [], 46 | activeEdge: 'ashburn', 47 | activeTest: 'bitrate', 48 | runTests: jest.fn(), 49 | })); 50 | 51 | const wrapper = shallow( 52 | {}) as any} 54 | getVoiceToken={(() => {}) as any} 55 | onComplete={() => {}} 56 | /> 57 | ); 58 | 59 | expect(wrapper.find(EdgeResult).find({ edge: 'ashburn' }).props()).toEqual({ 60 | activeTest: 'bitrate', 61 | codecPreferences: ['opus'], 62 | isActive: true, 63 | edge: 'ashburn', 64 | result: undefined, 65 | }); 66 | expect(wrapper.find(Button).find({ disabled: true }).length).toBe(2); 67 | }); 68 | 69 | it('should correctly render EdgeResult components when there are results', () => { 70 | mockUseTestRunner.mockImplementation(() => ({ 71 | isRunning: false, 72 | results: [{ errors: {} }], 73 | activeEdge: undefined, 74 | activeTest: undefined, 75 | runTests: jest.fn(), 76 | })); 77 | 78 | const wrapper = shallow( 79 | {}) as any} 81 | getVoiceToken={(() => {}) as any} 82 | onComplete={() => {}} 83 | /> 84 | ); 85 | 86 | expect(wrapper.find(EdgeResult).at(0).props()).toEqual({ 87 | activeTest: undefined, 88 | codecPreferences: ['opus'], 89 | isActive: false, 90 | edge: 'ashburn', 91 | result: { errors: {} }, 92 | }); 93 | }); 94 | 95 | it('should call the onComplete function with the results when the tests are complete', (done) => { 96 | mockUseTestRunner.mockImplementation(() => ({ 97 | isRunning: false, 98 | results: [], 99 | activeEdge: undefined, 100 | activeTest: undefined, 101 | runTests: jest.fn(() => Promise.resolve('mockResults')), 102 | })); 103 | 104 | const mockOnComplete = jest.fn(); 105 | 106 | const wrapper = shallow( 107 | {}) as any} 109 | getVoiceToken={(() => {}) as any} 110 | onComplete={mockOnComplete} 111 | /> 112 | ); 113 | 114 | wrapper.find(Button).at(0).simulate('click'); 115 | 116 | setImmediate(() => { 117 | expect(mockOnComplete).toHaveBeenCalledWith('mockResults'); 118 | done(); 119 | }); 120 | }); 121 | 122 | it('should correctly render an Alert when the bitrate test returns an "expired" error', () => { 123 | mockUseTestRunner.mockImplementation(() => ({ 124 | isRunning: false, 125 | results: [{ errors: { bitrate: { message: 'expired' } } }], 126 | activeEdge: undefined, 127 | activeTest: undefined, 128 | runTests: jest.fn(), 129 | })); 130 | 131 | const wrapper = shallow( 132 | {}) as any} 134 | getVoiceToken={(() => {}) as any} 135 | onComplete={() => {}} 136 | /> 137 | ); 138 | 139 | expect(wrapper.find(Alert).at(0)).toMatchInlineSnapshot(` 140 | 143 | 146 | 147 | App has expired 148 | 149 |  Please redeploy the app and try again. 150 | 151 | 152 | `); 153 | }); 154 | 155 | it('should correctly render an Alert when the preflight test returns an "expired" error', () => { 156 | mockUseTestRunner.mockImplementation(() => ({ 157 | isRunning: false, 158 | results: [{ errors: { preflight: { message: 'expired' } } }], 159 | activeEdge: undefined, 160 | activeTest: undefined, 161 | runTests: jest.fn(), 162 | })); 163 | 164 | const wrapper = shallow( 165 | {}) as any} 167 | getVoiceToken={(() => {}) as any} 168 | onComplete={() => {}} 169 | /> 170 | ); 171 | 172 | expect(wrapper.find(Alert).at(0)).toMatchInlineSnapshot(` 173 | 176 | 179 | 180 | App has expired 181 | 182 |  Please redeploy the app and try again. 183 | 184 | 185 | `); 186 | }); 187 | }); 188 | -------------------------------------------------------------------------------- /src/NetworkTestWidget/NetworkTestWidget.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import Alert from '../common/Alert/Alert'; 3 | import { Button, Typography } from '@material-ui/core'; 4 | import useTestRunner from './useTestRunner/useTestRunner'; 5 | import EdgeResult from './EdgeResult/EdgeResult'; 6 | import SettingsModal from './SettingsModal/SettingsModal'; 7 | import SettingsIcon from '@material-ui/icons/Settings'; 8 | import { DEFAULT_EDGES, DEFAULT_CODEC_PREFERENCES } from '../constants'; 9 | 10 | interface NetworkTestWidgetProps { 11 | getTURNCredentials: () => Promise; 12 | getVoiceToken: () => Promise; 13 | token?: string; 14 | iceServers?: RTCIceServer[]; 15 | onComplete: (results: any) => void; 16 | } 17 | 18 | const initialSettings = { 19 | edges: DEFAULT_EDGES, 20 | codecPreferences: DEFAULT_CODEC_PREFERENCES, 21 | }; 22 | 23 | export default function NetworkTestWidget({ getTURNCredentials, getVoiceToken, onComplete }: NetworkTestWidgetProps) { 24 | const { isRunning, results, activeEdge, runTests, activeTest } = useTestRunner(); 25 | const [isSettingsOpen, setIsSettingsOpen] = useState(false); 26 | const [settings, setSettings] = useState(initialSettings); 27 | 28 | async function startTest() { 29 | const testResults = await runTests(getVoiceToken, getTURNCredentials, settings.edges, settings.codecPreferences); 30 | onComplete(testResults); 31 | } 32 | 33 | const isExpired = results.some( 34 | (result) => result.errors.bitrate?.message === 'expired' || result.errors.preflight?.message === 'expired' 35 | ); 36 | 37 | return ( 38 |
39 | 40 | Connectivity and Bandwidth Tests 41 | 42 |
43 | {isExpired && ( 44 | 45 | 46 | App has expired Please redeploy the app and try again. 47 | 48 | 49 | )} 50 | {settings.edges.map((edge, i) => ( 51 | 59 | ))} 60 |
61 | 70 | 74 | { 77 | setIsSettingsOpen(false); 78 | setSettings(newSettings); 79 | }} 80 | /> 81 |
82 | ); 83 | } 84 | -------------------------------------------------------------------------------- /src/NetworkTestWidget/SettingsModal/SettingsModal.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Button } from '@material-ui/core'; 3 | import { Call } from '@twilio/voice-sdk'; 4 | import { mount, ReactWrapper } from 'enzyme'; 5 | import { Edge } from '../../types'; 6 | import SettingsModal from './SettingsModal'; 7 | 8 | jest.mock('../../constants', () => ({ 9 | DEFAULT_CODEC_PREFERENCES: ['opus'], 10 | DEFAULT_EDGES: ['ashburn', 'roaming'], 11 | MAX_SELECTED_EDGES: 3, 12 | MIN_SELECTED_EDGES: 1, 13 | })); 14 | 15 | const { PCMU, Opus } = Call.Codec; 16 | 17 | const changeCheckbox = (wrapper: ReactWrapper, edge: Edge, checked: boolean) => 18 | wrapper 19 | .find('input') 20 | .find({ name: edge, type: 'checkbox' }) 21 | .simulate('change', { target: { checked, name: edge } }); 22 | 23 | describe('the SettingsModal component', () => { 24 | const handleSettingsChange = jest.fn(); 25 | beforeEach(jest.clearAllMocks); 26 | 27 | it('should remove edges', () => { 28 | const wrapper = mount(); 29 | changeCheckbox(wrapper, 'ashburn', false); 30 | wrapper.find(Button).simulate('click'); 31 | expect(handleSettingsChange).toHaveBeenCalledWith({ 32 | codecPreferences: [Opus], 33 | edges: ['roaming'], 34 | }); 35 | }); 36 | 37 | it('should add edges', () => { 38 | const wrapper = mount(); 39 | changeCheckbox(wrapper, 'tokyo', true); 40 | wrapper.find(Button).simulate('click'); 41 | expect(handleSettingsChange).toHaveBeenCalledWith({ 42 | codecPreferences: [Opus], 43 | edges: ['ashburn', 'roaming', 'tokyo'], 44 | }); 45 | }); 46 | 47 | it('should not add more than 3 edges', () => { 48 | const wrapper = mount(); 49 | changeCheckbox(wrapper, 'sao-paulo', true); 50 | changeCheckbox(wrapper, 'singapore', true); // ignored 51 | wrapper.find(Button).simulate('click'); 52 | expect(handleSettingsChange).toHaveBeenCalledWith({ 53 | codecPreferences: [Opus], 54 | edges: ['ashburn', 'roaming', 'sao-paulo'], 55 | }); 56 | }); 57 | 58 | it('should not remove the last edge', () => { 59 | const wrapper = mount(); 60 | changeCheckbox(wrapper, 'ashburn', false); 61 | changeCheckbox(wrapper, 'roaming', false); // ignored 62 | wrapper.find(Button).simulate('click'); 63 | expect(handleSettingsChange).toHaveBeenCalledWith({ 64 | codecPreferences: [Opus], 65 | edges: ['roaming'], 66 | }); 67 | }); 68 | 69 | it('should return the correct value when selecting PCMU + OPUS', () => { 70 | const wrapper = mount(); 71 | wrapper 72 | .find('input') 73 | .find({ name: PCMU + Opus, type: 'radio' }) 74 | .simulate('change', { target: { name: PCMU + Opus } }); 75 | wrapper.find(Button).simulate('click'); 76 | expect(handleSettingsChange).toHaveBeenCalledWith({ 77 | codecPreferences: [PCMU, Opus], 78 | edges: ['ashburn', 'roaming'], 79 | }); 80 | }); 81 | 82 | it('should return the correct value when selecting OPUS + PCMU', () => { 83 | const wrapper = mount(); 84 | wrapper 85 | .find('input') 86 | .find({ name: Opus + PCMU, type: 'radio' }) 87 | .simulate('change', { target: { name: PCMU + Opus } }); 88 | wrapper.find(Button).simulate('click'); 89 | expect(handleSettingsChange).toHaveBeenCalledWith({ 90 | codecPreferences: [PCMU, Opus], 91 | edges: ['ashburn', 'roaming'], 92 | }); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /src/NetworkTestWidget/SettingsModal/SettingsModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { 3 | Button, 4 | Checkbox, 5 | Dialog, 6 | Divider, 7 | FormControlLabel, 8 | FormGroup, 9 | Grid, 10 | Radio, 11 | Typography, 12 | } from '@material-ui/core'; 13 | import { Call } from '@twilio/voice-sdk'; 14 | import { DEFAULT_CODEC_PREFERENCES, DEFAULT_EDGES, MAX_SELECTED_EDGES, MIN_SELECTED_EDGES } from '../../constants'; 15 | import { makeStyles } from '@material-ui/core/styles'; 16 | import { Edge } from '../../types'; 17 | 18 | const { PCMU, Opus } = Call.Codec; 19 | 20 | const useStyles = makeStyles({ 21 | container: { 22 | padding: '1em', 23 | maxWidth: '400px', 24 | }, 25 | innerContainer: { 26 | display: 'block', 27 | padding: '1em', 28 | width: '100%', 29 | }, 30 | headerContainer: { 31 | display: 'flex', 32 | justifyContent: 'space-between', 33 | }, 34 | }); 35 | 36 | type InitialState = { 37 | [key in Edge]: boolean; 38 | }; 39 | 40 | const initialState: InitialState = { 41 | ashburn: false, 42 | dublin: false, 43 | frankfurt: false, 44 | roaming: false, 45 | 'sao-paulo': false, 46 | singapore: false, 47 | sydney: false, 48 | tokyo: false, 49 | 'ashburn-ix': false, 50 | 'london-ix': false, 51 | 'frankfurt-ix': false, 52 | 'san-jose-ix': false, 53 | 'singapore-ix': false, 54 | }; 55 | 56 | DEFAULT_EDGES.forEach((edge) => (initialState[edge] = true)); 57 | 58 | const codecMap = { 59 | [Opus]: [Opus], 60 | [PCMU]: [PCMU], 61 | [Opus + PCMU]: [Opus, PCMU], 62 | [PCMU + Opus]: [PCMU, Opus], 63 | }; 64 | 65 | const getEdgeArray = (edgeObj: InitialState) => 66 | Object.entries(edgeObj) 67 | .filter((e) => e[1]) 68 | .map((e) => e[0]); 69 | 70 | export default function SettingsModal({ 71 | isOpen, 72 | onSettingsChange, 73 | }: { 74 | isOpen: boolean; 75 | onSettingsChange: (q: any) => void; 76 | }) { 77 | const classes = useStyles(); 78 | 79 | const [edges, setEdges] = useState(initialState); 80 | const [codec, setCodec] = useState(DEFAULT_CODEC_PREFERENCES.join('')); 81 | const selectedEdges = Object.values(edges).filter((isSelected) => isSelected).length; 82 | 83 | const handleEdgeChange = (event: React.ChangeEvent) => { 84 | const edgeName = event.target.name; 85 | const isChecked = event.target.checked; 86 | 87 | setEdges((prevEdges) => { 88 | const newEdges = { ...prevEdges, [edgeName]: isChecked }; 89 | const newEdgesArrayLength = getEdgeArray(newEdges).length; 90 | 91 | if (newEdgesArrayLength >= MIN_SELECTED_EDGES && newEdgesArrayLength <= MAX_SELECTED_EDGES) { 92 | return newEdges; 93 | } else { 94 | return prevEdges; 95 | } 96 | }); 97 | }; 98 | 99 | const handleCodecChange = (event: React.ChangeEvent) => setCodec(event.target.name); 100 | 101 | const handleClose = () => 102 | onSettingsChange({ 103 | edges: getEdgeArray(edges), 104 | codecPreferences: codecMap[codec], 105 | }); 106 | 107 | return ( 108 | 109 | 110 |
111 |
112 | 113 | Edge Locations: 114 | 115 | {`${selectedEdges} of ${MAX_SELECTED_EDGES}`} 116 |
117 | 118 | 119 | 120 | } 122 | label="Ashburn" 123 | /> 124 | } 126 | label="Dublin" 127 | /> 128 | } 130 | label="Frankfurt" 131 | /> 132 | } 134 | label="Roaming" 135 | /> 136 | } 138 | label="Sao Paulo" 139 | /> 140 | } 142 | label="Singapore" 143 | /> 144 | } 146 | label="Sydney" 147 | /> 148 | 149 | 150 | } 152 | label="Tokyo" 153 | /> 154 | } 156 | label="Ashburn IX" 157 | /> 158 | } 160 | label="London IX" 161 | /> 162 | } 164 | label="Frankfurt IX" 165 | /> 166 | } 168 | label="San Jose IX" 169 | /> 170 | } 172 | label="Singapore IX" 173 | /> 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | Codec Preferences: 182 | 183 | 184 | 185 | } 187 | label="Opus" 188 | /> 189 | } 191 | label="PCMU" 192 | /> 193 | } 195 | label="Opus, PCMU" 196 | /> 197 | } 199 | label="PCMU, Opus" 200 | /> 201 | 202 | 203 | 204 |
205 | 206 | 207 | 210 | 211 |
212 | ); 213 | } 214 | -------------------------------------------------------------------------------- /src/NetworkTestWidget/Tests/Tests.test.ts: -------------------------------------------------------------------------------- 1 | import { bitrateTestRunner, preflightTestRunner, BITRATE_TEST_DURATION } from './Tests'; 2 | import { Call, Device } from '@twilio/voice-sdk'; 3 | import { EventEmitter } from 'events'; 4 | 5 | class MockBitrateTest extends EventEmitter { 6 | stop = jest.fn(); 7 | } 8 | 9 | const mockBitrateTest = new MockBitrateTest(); 10 | 11 | const mockPreflightTest = new EventEmitter(); 12 | 13 | const mockTurnServers: RTCIceServer[] = [{ url: '', urls: '' }]; 14 | 15 | jest.mock('@twilio/rtc-diagnostics', () => ({ 16 | testMediaConnectionBitrate: jest.fn(() => mockBitrateTest), 17 | MediaConnectionBitrateTest: { 18 | Events: { 19 | Bitrate: 'Bitrate', 20 | Error: 'Error', 21 | End: 'End', 22 | }, 23 | }, 24 | })); 25 | 26 | jest.mock('@twilio/voice-sdk', () => ({ 27 | Device: { 28 | runPreflight: jest.fn(() => mockPreflightTest), 29 | packageName: '@twilio/voice-sdk', 30 | }, 31 | PreflightTest: { 32 | Events: { 33 | Completed: 'Completed', 34 | Connected: 'Connected', 35 | Failed: 'Failed', 36 | Sample: 'Sample', 37 | Warning: 'Warning', 38 | }, 39 | }, 40 | Call: { 41 | Codec: { 42 | Opus: 'opus', 43 | PCMU: 'pcmu', 44 | }, 45 | }, 46 | })); 47 | 48 | describe('the bitrateTestRunner function', () => { 49 | beforeEach(jest.clearAllMocks); 50 | 51 | it('should resolve on "End" event', () => { 52 | const bitrateTest = bitrateTestRunner('ashburn', mockTurnServers); 53 | mockBitrateTest.emit('End', { report: 'testReport' }); 54 | return expect(bitrateTest).resolves.toEqual({ report: 'testReport' }); 55 | }); 56 | 57 | it('should reject on "Error" event', () => { 58 | const bitrateTest = bitrateTestRunner('ashburn', mockTurnServers); 59 | mockBitrateTest.emit('Error', { error: 'testError' }); 60 | return expect(bitrateTest).rejects.toEqual({ error: 'testError' }); 61 | }); 62 | 63 | it('should call "stop" method after BITRATE_TEST_DURATION elapses', () => { 64 | jest.useFakeTimers(); 65 | bitrateTestRunner('ashburn', mockTurnServers); 66 | expect(mockBitrateTest.stop).not.toHaveBeenCalled(); 67 | jest.advanceTimersByTime(BITRATE_TEST_DURATION); 68 | expect(mockBitrateTest.stop).toHaveBeenCalled(); 69 | }); 70 | }); 71 | 72 | describe('the preflightTestRunner function', () => { 73 | it('should be called with the correct options', () => { 74 | preflightTestRunner('ashburn', 'token', mockTurnServers, [Call.Codec.Opus]); 75 | return expect(Device.runPreflight).toHaveBeenCalledWith('token', { 76 | codecPreferences: ['opus'], 77 | edge: 'ashburn', 78 | fakeMicInput: true, 79 | iceServers: [{ url: '', urls: '' }], 80 | signalingTimeoutMs: 10000, 81 | }); 82 | }); 83 | 84 | it('should resolve on "Completed" event', () => { 85 | const preflightTest = preflightTestRunner('ashburn', 'token', mockTurnServers, [Call.Codec.Opus]); 86 | mockPreflightTest.emit('Completed', { report: 'testReport' }); 87 | return expect(preflightTest).resolves.toEqual({ report: 'testReport' }); 88 | }); 89 | 90 | describe('"Failed" event', () => { 91 | it('should reject', () => { 92 | const preflightTest = preflightTestRunner('ashburn', 'token', mockTurnServers, [Call.Codec.Opus]); 93 | mockPreflightTest.emit('Failed', { report: 'testReport' }); 94 | return expect(preflightTest).rejects.toEqual({ 95 | report: 'testReport', 96 | hasConnected: false, 97 | latestSample: undefined, 98 | }); 99 | }); 100 | 101 | it('should reject with "hasConnected" set to true after "Connected" event', () => { 102 | const preflightTest = preflightTestRunner('ashburn', 'token', mockTurnServers, [Call.Codec.Opus]); 103 | mockPreflightTest.emit('Connected'); 104 | mockPreflightTest.emit('Failed', { report: 'testReport' }); 105 | return expect(preflightTest).rejects.toEqual({ 106 | report: 'testReport', 107 | hasConnected: true, 108 | latestSample: undefined, 109 | }); 110 | }); 111 | 112 | it('should reject with "latestSample" populated with the latest sample', () => { 113 | const preflightTest = preflightTestRunner('ashburn', 'token', mockTurnServers, [Call.Codec.Opus]); 114 | mockPreflightTest.emit('Connected'); 115 | mockPreflightTest.emit('Sample', 'mockSample1'); 116 | mockPreflightTest.emit('Sample', 'mockSample2'); 117 | mockPreflightTest.emit('Failed', { report: 'testReport' }); 118 | return expect(preflightTest).rejects.toEqual({ 119 | report: 'testReport', 120 | hasConnected: true, 121 | latestSample: 'mockSample2', 122 | }); 123 | }); 124 | }); 125 | }); 126 | -------------------------------------------------------------------------------- /src/NetworkTestWidget/Tests/Tests.ts: -------------------------------------------------------------------------------- 1 | import { testMediaConnectionBitrate, MediaConnectionBitrateTest } from '@twilio/rtc-diagnostics'; 2 | import { Device, Call, PreflightTest } from '@twilio/voice-sdk'; 3 | import { getLogger } from 'loglevel'; 4 | import { regionalizeIceUrls } from '../../utils'; 5 | import { APP_NAME, LOG_LEVEL } from '../../constants'; 6 | import { Edge } from '../../types'; 7 | import RTCSample from '@twilio/voice-sdk/es5/twilio/rtc/sample'; 8 | 9 | const log = getLogger(APP_NAME); 10 | 11 | getLogger(Device.packageName).setLevel(LOG_LEVEL, false); 12 | 13 | const preflightOptions: PreflightTest.Options = { 14 | signalingTimeoutMs: 10000, 15 | fakeMicInput: true, 16 | }; 17 | 18 | export const BITRATE_TEST_DURATION = 15000; 19 | 20 | export function preflightTestRunner( 21 | edge: Edge, 22 | token: string, 23 | iceServers: RTCIceServer[], 24 | codecPreferences: Call.Codec[] 25 | ) { 26 | const updatedIceServers = regionalizeIceUrls(edge, iceServers); 27 | 28 | return new Promise((resolve, reject) => { 29 | const preflightTest = Device.runPreflight(token, { 30 | ...preflightOptions, 31 | edge: edge, 32 | iceServers: updatedIceServers, 33 | codecPreferences, 34 | }); 35 | let hasConnected = false; 36 | let latestSample: RTCSample; 37 | 38 | preflightTest.on(PreflightTest.Events.Completed, (report: PreflightTest.Report) => { 39 | log.debug('Preflight Test - report: ', report); 40 | resolve(report); 41 | }); 42 | 43 | preflightTest.on(PreflightTest.Events.Connected, () => { 44 | log.debug('Preflight Test - connected'); 45 | hasConnected = true; 46 | }); 47 | 48 | preflightTest.on(PreflightTest.Events.Sample, (sample: RTCSample) => { 49 | latestSample = sample; 50 | }); 51 | 52 | preflightTest.on(PreflightTest.Events.Failed, (error) => { 53 | log.debug('Preflight Test - failed: ', error); 54 | error.hasConnected = hasConnected; 55 | error.latestSample = latestSample; 56 | reject(error); 57 | }); 58 | 59 | preflightTest.on(PreflightTest.Events.Warning, (warningName, warningData) => { 60 | log.debug('Preflight Test - warning: ', warningName, warningData); 61 | }); 62 | }); 63 | } 64 | 65 | export function bitrateTestRunner(edge: Edge, iceServers: MediaConnectionBitrateTest.Options['iceServers']) { 66 | const updatedIceServers = regionalizeIceUrls(edge, iceServers); 67 | 68 | return new Promise((resolve, reject) => { 69 | const bitrateTest = testMediaConnectionBitrate({ iceServers: updatedIceServers }); 70 | 71 | bitrateTest.on(MediaConnectionBitrateTest.Events.Bitrate, (bitrate) => { 72 | log.debug('Bitrate Test - bitrate: ', bitrate); 73 | }); 74 | 75 | bitrateTest.on(MediaConnectionBitrateTest.Events.Error, (error) => { 76 | log.debug('Bitrate Test - error: ', error); 77 | reject(error); 78 | }); 79 | 80 | bitrateTest.on(MediaConnectionBitrateTest.Events.End, (report) => { 81 | log.debug('Bitrate Test - end: ', report); 82 | resolve(report); 83 | }); 84 | 85 | setTimeout(() => { 86 | bitrateTest.stop(); 87 | }, BITRATE_TEST_DURATION); 88 | }); 89 | } 90 | -------------------------------------------------------------------------------- /src/NetworkTestWidget/useTestRunner/useTestRunner.test.ts: -------------------------------------------------------------------------------- 1 | import { act, renderHook } from '@testing-library/react-hooks'; 2 | import { bitrateTestRunner, preflightTestRunner } from '../Tests/Tests'; 3 | import { Call } from '@twilio/voice-sdk'; 4 | import useTestRunner from './useTestRunner'; 5 | 6 | const resolvePromise = (value: any) => new Promise((resolve) => setTimeout(() => resolve(value), 10)); 7 | const rejectPromise = (value: any) => new Promise((_, reject) => setTimeout(() => reject(value), 10)); 8 | 9 | const mockGetVoiceToken = jest.fn(() => resolvePromise('mockToken')); 10 | const mockGetTURNCredentils = jest.fn(() => resolvePromise([{ url: 'mockTurnURL', urls: 'mockTurnURLs' }])); 11 | 12 | jest.mock('../Tests/Tests'); 13 | 14 | const mockBitrateTestRunner = bitrateTestRunner as jest.Mock; 15 | const mockPreflightTestRunner = preflightTestRunner as jest.Mock; 16 | 17 | describe('the useTestRunner hook', () => { 18 | beforeEach(jest.clearAllMocks); 19 | 20 | it('should correcly run all tests and update its state accordingly', async () => { 21 | mockBitrateTestRunner.mockImplementation(() => resolvePromise('mockBitrateResult')); 22 | mockPreflightTestRunner.mockImplementation(() => resolvePromise('mockPreflightResult')); 23 | 24 | const { result, waitForNextUpdate } = renderHook(useTestRunner); 25 | 26 | expect(result.current.isRunning).toBe(false); 27 | 28 | // Start the tests 29 | act(() => { 30 | result.current.runTests(mockGetVoiceToken, mockGetTURNCredentils, ['ashburn', 'tokyo'], [Call.Codec.Opus]); 31 | }); 32 | 33 | expect(result.current.isRunning).toBe(true); 34 | expect(result.current.activeTest).toBe('Preflight Test'); 35 | expect(result.current.activeEdge).toBe('ashburn'); 36 | 37 | await waitForNextUpdate(); // Wait for preflight test to complete 38 | 39 | expect(result.current.activeTest).toBe('Bitrate Test'); 40 | 41 | await waitForNextUpdate(); // Wait for bitrate test to complete 42 | 43 | // Expect results for first reigon 44 | expect(result.current.results).toEqual([ 45 | { errors: {}, edge: 'ashburn', results: { bitrate: 'mockBitrateResult', preflight: 'mockPreflightResult' } }, 46 | ]); 47 | 48 | // Prepare for next edge 49 | expect(result.current.activeEdge).toBe('tokyo'); 50 | expect(result.current.activeTest).toBe('Preflight Test'); 51 | 52 | await waitForNextUpdate(); // Wait for preflight test to complete 53 | 54 | expect(result.current.activeTest).toBe('Bitrate Test'); 55 | 56 | await waitForNextUpdate(); // Wait for bitrate test to complete 57 | 58 | // Expect all results 59 | expect(result.current.results).toEqual([ 60 | { errors: {}, edge: 'ashburn', results: { bitrate: 'mockBitrateResult', preflight: 'mockPreflightResult' } }, 61 | { errors: {}, edge: 'tokyo', results: { bitrate: 'mockBitrateResult', preflight: 'mockPreflightResult' } }, 62 | ]); 63 | 64 | // Expect hook to reset its state 65 | expect(result.current.isRunning).toBe(false); 66 | expect(result.current.activeTest).toBe(undefined); 67 | expect(result.current.activeEdge).toBe(undefined); 68 | 69 | // Expect this function to be called once for every edge 70 | expect(mockGetVoiceToken).toHaveBeenCalledTimes(2); 71 | 72 | // Expect this function to be called twice for every edge 73 | expect(mockGetTURNCredentils).toHaveBeenCalledTimes(4); 74 | }); 75 | 76 | it('should not run bitrate test when preflight test fails', async () => { 77 | mockBitrateTestRunner.mockImplementation(() => resolvePromise('mockBitrateReport')); 78 | mockPreflightTestRunner.mockImplementation(() => rejectPromise('mockPreflightError')); 79 | 80 | const { result, waitForNextUpdate } = renderHook(useTestRunner); 81 | expect(result.current.isRunning).toBe(false); 82 | 83 | // Start tests 84 | act(() => { 85 | result.current.runTests(mockGetVoiceToken, mockGetTURNCredentils, ['ashburn'], [Call.Codec.Opus]); 86 | }); 87 | 88 | expect(result.current.isRunning).toBe(true); 89 | expect(result.current.activeEdge).toBe('ashburn'); 90 | 91 | await waitForNextUpdate(); // Wait for preflight test to fail 92 | 93 | // Expect results object with error 94 | expect(result.current.results).toEqual([ 95 | { errors: { preflight: 'mockPreflightError' }, edge: 'ashburn', results: {} }, 96 | ]); 97 | 98 | expect(result.current.isRunning).toBe(false); 99 | 100 | expect(mockGetVoiceToken).toHaveBeenCalledTimes(1); 101 | expect(mockGetTURNCredentils).toHaveBeenCalledTimes(1); 102 | }); 103 | 104 | it('should correctly report bitrate errors when the preflight test succeeds', async () => { 105 | mockBitrateTestRunner.mockImplementation(() => rejectPromise('mockBitrateError')); 106 | mockPreflightTestRunner.mockImplementation(() => resolvePromise('mockPreflightReport')); 107 | 108 | const { result, waitForNextUpdate } = renderHook(useTestRunner); 109 | expect(result.current.isRunning).toBe(false); 110 | 111 | // Start tests 112 | act(() => { 113 | result.current.runTests(mockGetVoiceToken, mockGetTURNCredentils, ['ashburn'], [Call.Codec.Opus]); 114 | }); 115 | 116 | expect(result.current.isRunning).toBe(true); 117 | expect(result.current.activeEdge).toBe('ashburn'); 118 | 119 | await waitForNextUpdate(); // Wait for preflight test to complete 120 | 121 | expect(result.current.activeTest).toBe('Bitrate Test'); 122 | 123 | await waitForNextUpdate(); // Wait for bitrate test to fail 124 | 125 | // Expect results with bitrate error 126 | expect(result.current.results).toEqual([ 127 | { errors: { bitrate: 'mockBitrateError' }, edge: 'ashburn', results: { preflight: 'mockPreflightReport' } }, 128 | ]); 129 | 130 | expect(result.current.isRunning).toBe(false); 131 | 132 | expect(mockGetVoiceToken).toHaveBeenCalledTimes(1); 133 | expect(mockGetTURNCredentils).toHaveBeenCalledTimes(2); 134 | }); 135 | }); 136 | -------------------------------------------------------------------------------- /src/NetworkTestWidget/useTestRunner/useTestRunner.ts: -------------------------------------------------------------------------------- 1 | import { Call, TwilioError } from '@twilio/voice-sdk'; 2 | import { DiagnosticError } from '@twilio/rtc-diagnostics'; 3 | import { bitrateTestRunner, preflightTestRunner } from '../Tests/Tests'; 4 | import { Edge, TestResults, NetworkTestName } from '../../types'; 5 | import { useState, useCallback } from 'react'; 6 | 7 | export default function useTestRunner() { 8 | const [isRunning, setIsRunning] = useState(false); 9 | const [activeTest, setActiveTest] = useState(); 10 | const [results, setResults] = useState([]); 11 | const [activeEdge, setActiveEdge] = useState(); 12 | 13 | const runTests = useCallback( 14 | async ( 15 | getVoiceToken: () => Promise, 16 | getTURNCredentials: () => Promise, 17 | edges: Edge[], 18 | codecPreferences: Call.Codec[] 19 | ) => { 20 | setIsRunning(true); 21 | setResults([]); 22 | const allResults: TestResults[] = []; 23 | 24 | for (const edge of edges) { 25 | const testResults: TestResults = { 26 | edge, 27 | results: {}, 28 | errors: {}, 29 | }; 30 | 31 | setActiveEdge(edge); 32 | setActiveTest('Preflight Test'); 33 | 34 | try { 35 | const voiceToken = await getVoiceToken(); 36 | const iceServers = await getTURNCredentials(); 37 | testResults.results.preflight = await preflightTestRunner(edge, voiceToken, iceServers, codecPreferences); 38 | } catch (err) { 39 | testResults.errors.preflight = err as TwilioError.TwilioError; 40 | } 41 | 42 | if (!testResults.errors.preflight) { 43 | setActiveTest('Bitrate Test'); 44 | try { 45 | const iceServers = await getTURNCredentials(); 46 | testResults.results.bitrate = await bitrateTestRunner(edge, iceServers); 47 | } catch (err) { 48 | testResults.errors.bitrate = err as DiagnosticError; 49 | } 50 | } 51 | 52 | setResults((prevResults) => [...prevResults, testResults]); 53 | allResults.push(testResults); 54 | } 55 | 56 | setActiveTest(undefined); 57 | setActiveEdge(undefined); 58 | setIsRunning(false); 59 | return allResults; 60 | }, 61 | [] 62 | ); 63 | 64 | return { 65 | isRunning, 66 | activeTest, 67 | results, 68 | activeEdge, 69 | runTests, 70 | }; 71 | } 72 | -------------------------------------------------------------------------------- /src/ResultWidget/ResultIcon/ResultIcon.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { mount } from 'enzyme'; 3 | import CloseIcon from '@material-ui/icons/Close'; 4 | import CheckIcon from '@material-ui/icons/CheckCircleOutline'; 5 | import WarningIcon from '@material-ui/icons/ReportProblemOutlined'; 6 | 7 | import ResultIcon from './ResultIcon'; 8 | 9 | jest.mock('../rows', () => ({ 10 | rows: [ 11 | { 12 | getWarning: (result: any) => result.warnings && result.warnings.length, 13 | }, 14 | ], 15 | })); 16 | 17 | const components = [CloseIcon, CheckIcon, WarningIcon]; 18 | 19 | describe('the ResultIcon component', () => { 20 | [ 21 | { 22 | testName: 'With errors and no warnings', 23 | data: { errors: ['foo'] }, 24 | assertions: [true, false, false], 25 | }, 26 | { 27 | testName: 'With warnings and no errors', 28 | data: { warnings: ['foo'] }, 29 | assertions: [false, false, true], 30 | }, 31 | { 32 | testName: 'With errors and warnings', 33 | data: { errors: ['foo'], warnings: ['foo'] }, 34 | assertions: [true, false, false], 35 | }, 36 | { 37 | testName: 'Without errors and warnings', 38 | data: {}, 39 | assertions: [false, true, false], 40 | }, 41 | ].forEach((test) => { 42 | it(test.testName, () => { 43 | const wrapper = mount(); 44 | test.assertions.forEach((value, i) => { 45 | expect(wrapper.find(components[i]).exists()).toBe(value); 46 | }); 47 | }); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /src/ResultWidget/ResultIcon/ResultIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { makeStyles, Theme } from '@material-ui/core/styles'; 3 | 4 | import CloseIcon from '@material-ui/icons/Close'; 5 | import CheckIcon from '@material-ui/icons/CheckCircleOutline'; 6 | import WarningIcon from '@material-ui/icons/ReportProblemOutlined'; 7 | import { TestResults } from '../../types'; 8 | import { rows } from '../rows'; 9 | 10 | const useStyles = makeStyles((theme: Theme) => ({ 11 | close: { 12 | fill: theme.palette.error.main, 13 | }, 14 | warning: { 15 | fill: theme.palette.warning.main, 16 | }, 17 | check: { 18 | fill: theme.palette.success.main, 19 | }, 20 | })); 21 | 22 | interface ResultIconProps { 23 | result?: TestResults; 24 | } 25 | 26 | export default function ResultIcon(props: ResultIconProps) { 27 | const classes = useStyles(); 28 | const result = props.result; 29 | const hasError = Object.values(result?.errors ?? {}).length > 0; 30 | const hasWarning = result && rows.some((row) => row.getWarning?.(result)); 31 | 32 | return ( 33 | <> 34 | {hasError && } 35 | {!hasError && hasWarning && } 36 | {!hasError && !hasWarning && } 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/ResultWidget/ResultWidget.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ResultWidget from './ResultWidget'; 3 | import { shallow } from 'enzyme'; 4 | import { TableContainer, Table } from '@material-ui/core'; 5 | 6 | import toJson, { OutputMapper } from 'enzyme-to-json'; 7 | 8 | const testResults: any = [ 9 | { 10 | edge: 'roaming', 11 | results: { 12 | preflight: { 13 | callSid: 'CA12345', 14 | edge: 'ashburn', 15 | networkTiming: { 16 | peerConnection: { 17 | duration: 1618, 18 | }, 19 | }, 20 | samples: [ 21 | { 22 | bytesReceived: 6296, 23 | }, 24 | ], 25 | selectedEdge: 'roaming', 26 | selectedIceCandidatePairStats: { 27 | localCandidate: { 28 | relayProtocol: 'udp', 29 | }, 30 | }, 31 | stats: { 32 | jitter: { 33 | average: 5.4444, 34 | max: 9, 35 | min: 2, 36 | }, 37 | mos: { 38 | average: 4.3868, 39 | max: 4.4067161340069765, 40 | min: 4.171315102552577, 41 | }, 42 | rtt: { 43 | average: 69.667, 44 | max: 212, 45 | min: 54, 46 | }, 47 | }, 48 | testTiming: { 49 | start: 1593709997840, 50 | end: 1593710025637, 51 | duration: 27797, 52 | }, 53 | totals: { 54 | packetsLostFraction: 2, 55 | }, 56 | warnings: [{ name: 'high-latency' }], 57 | isTurnRequired: true, 58 | callQuality: 'excellent', 59 | }, 60 | bitrate: { 61 | averageBitrate: 2875.4694113266423, 62 | }, 63 | }, 64 | errors: {}, 65 | }, 66 | { 67 | edge: 'tokyo', 68 | results: {}, 69 | errors: { 70 | preflight: { 71 | code: 31000, 72 | }, 73 | }, 74 | }, 75 | ]; 76 | 77 | // This function is used to clean up the jest snapshot, making it easier to read. 78 | const snapshotMapper: OutputMapper = (json) => { 79 | const match = json.type.match(/WithStyles\(ForwardRef\((\w+)\)\)/); 80 | if (match) { 81 | json.type = match[1]; 82 | } 83 | return json; 84 | }; 85 | 86 | describe('the ResultWidget component', () => { 87 | it('should not render when results are not present', () => { 88 | const wrapper = shallow(); 89 | expect(wrapper.find(TableContainer).exists()).toBe(false); 90 | }); 91 | 92 | it('should render correctly when results are present', () => { 93 | const wrapper = shallow(); 94 | expect(toJson(wrapper.find(Table), { map: snapshotMapper })).toMatchSnapshot(); 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /src/ResultWidget/ResultWidget.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Paper, 4 | TableContainer, 5 | Table, 6 | TableHead, 7 | TableBody, 8 | TableRow, 9 | TableCell, 10 | Tooltip, 11 | Typography, 12 | makeStyles, 13 | createStyles, 14 | Theme, 15 | } from '@material-ui/core'; 16 | import InfoIcon from '@material-ui/icons/Info'; 17 | 18 | import ResultIcon from './ResultIcon/ResultIcon'; 19 | import { getBestEdge, getEdgeName } from '../utils'; 20 | import { TestWarnings, TestResults } from '../types'; 21 | import { darken, fade, lighten } from '@material-ui/core/styles/colorManipulator'; 22 | import { rows } from './rows'; 23 | 24 | const useStyles = makeStyles((theme: Theme) => { 25 | const getBackgroundColor = theme.palette.type === 'light' ? lighten : darken; 26 | return createStyles({ 27 | table: { 28 | tableLayout: 'fixed', 29 | borderTop: `1px solid ${theme.palette.divider}`, 30 | '& td, & th': { 31 | width: '260px', 32 | borderRight: `1px solid 33 | ${ 34 | // Same implementation as material-ui's table borderBottom. 35 | theme.palette.type === 'light' 36 | ? lighten(fade(theme.palette.divider, 1), 0.88) 37 | : darken(fade(theme.palette.divider, 1), 0.68) 38 | }`, 39 | }, 40 | '& td:last-child, & th:last-child': { 41 | borderRight: 'none', 42 | }, 43 | '& th, & td:first-child': { 44 | fontWeight: 'bold', 45 | }, 46 | }, 47 | tableCellContent: { 48 | display: 'flex', 49 | alignItems: 'center', 50 | justifyContent: 'space-between', 51 | wordBreak: 'break-word', 52 | '& svg': { 53 | fill: '#000', 54 | }, 55 | }, 56 | headerContent: { 57 | display: 'flex', 58 | '& p': { 59 | fontWeight: 'bold', 60 | marginLeft: '12px', 61 | }, 62 | }, 63 | bestEdgeCell: { 64 | background: getBackgroundColor(theme.palette.success.main, 0.9), 65 | }, 66 | [TestWarnings.warn]: { 67 | background: getBackgroundColor(theme.palette.warning.main, 0.9), 68 | }, 69 | [TestWarnings.error]: { 70 | background: getBackgroundColor(theme.palette.error.main, 0.9), 71 | }, 72 | }); 73 | }); 74 | 75 | export default function ResultWidget(props: { results?: TestResults[] }) { 76 | const { results } = props; 77 | const classes = useStyles(); 78 | 79 | const getTableCellClass = (isBestEdge: boolean, warning?: TestWarnings) => { 80 | if (warning?.includes('warn')) return classes[TestWarnings.warn]; 81 | if (warning === TestWarnings.error) return classes[TestWarnings.error]; 82 | if (isBestEdge) return classes.bestEdgeCell; 83 | }; 84 | 85 | if (!results) return null; 86 | 87 | const bestEdge = getBestEdge(results); 88 | 89 | return ( 90 | 91 | 92 | 93 | 94 | 95 | {results.map((result) => { 96 | const isBestEdge = !!bestEdge && bestEdge.edge === result.edge; 97 | const className = getTableCellClass(isBestEdge); 98 | const edgeName = getEdgeName(result) + (isBestEdge && results.length > 1 ? ' (Recommended)' : ''); 99 | return ( 100 | 101 |
102 | 103 | {edgeName} 104 |
105 |
106 | ); 107 | })} 108 |
109 |
110 | 111 | {rows.map((row) => { 112 | const tooltipTitle = row.tooltipContent?.label; 113 | return ( 114 | 115 | 116 |
117 | {row.label} 118 | {tooltipTitle && ( 119 | 120 | 121 | 122 | )} 123 |
124 |
125 | {results.map((result) => { 126 | const value = row.getValue(result); 127 | const warning = row.getWarning?.(result); 128 | const className = getTableCellClass(!!bestEdge && bestEdge.edge === result.edge, warning); 129 | const tooltipContent = warning ? row.tooltipContent?.[warning] : null; 130 | 131 | return ( 132 | 133 |
134 | {value} 135 | {tooltipContent && ( 136 | 137 | 138 | 139 | )} 140 |
141 |
142 | ); 143 | })} 144 |
145 | ); 146 | })} 147 |
148 |
149 |
150 | ); 151 | } 152 | -------------------------------------------------------------------------------- /src/ResultWidget/rows/bandwidth/bandwidth.test.tsx: -------------------------------------------------------------------------------- 1 | import bandwidth from './bandwidth'; 2 | import { set } from 'lodash'; 3 | import { TestResults, TestWarnings } from '../../../types'; 4 | 5 | describe('the bandwidth row', () => { 6 | describe('the getValue function', () => { 7 | it('should return undefined when preflight tests results are not available', () => { 8 | const testResult = set({}, 'results.bitrate.averageBitrate', 1000) as TestResults; 9 | expect(bandwidth.getValue(testResult)).toBe(undefined); 10 | }); 11 | 12 | it('should return the averageBitrate when preflight tests results are available', () => { 13 | let testResult = set({}, 'results.bitrate.averageBitrate', 1000.234) as TestResults; 14 | testResult = set(testResult, 'results.preflight', {}); 15 | expect(bandwidth.getValue(testResult)).toBe(1000); 16 | }); 17 | }); 18 | 19 | describe('the getWarning function', () => { 20 | it('should return none when there are no preflight results', () => { 21 | const testResult = set({}, 'results.bitrate.averageBitrate', 10) as TestResults; 22 | expect(bandwidth.getWarning?.(testResult)).toBe(TestWarnings.none); 23 | }); 24 | 25 | describe('when the codec is PCMU', () => { 26 | const baseResult = set({}, 'results.preflight.samples[0].codecName', 'pcmu'); 27 | it('should return none when the bitrate is 100', () => { 28 | let testResult = set(baseResult, 'results.bitrate.averageBitrate', 100) as TestResults; 29 | expect(bandwidth.getWarning?.(testResult)).toBe(TestWarnings.none); 30 | }); 31 | 32 | it('should return warn when the bitrate is below 100', () => { 33 | let testResult = set(baseResult, 'results.bitrate.averageBitrate', 99) as TestResults; 34 | expect(bandwidth.getWarning?.(testResult)).toBe(TestWarnings.warn); 35 | }); 36 | }); 37 | 38 | describe('when the codec is Opus', () => { 39 | const baseResult = set({}, 'results.preflight.samples[0].codecName', 'opus'); 40 | it('should return none when the bitrate is 40', () => { 41 | let testResult = set(baseResult, 'results.bitrate.averageBitrate', 40) as TestResults; 42 | expect(bandwidth.getWarning?.(testResult)).toBe(TestWarnings.none); 43 | }); 44 | 45 | it('should return warn when the bitrate is below 40', () => { 46 | let testResult = set(baseResult, 'results.bitrate.averageBitrate', 39) as TestResults; 47 | expect(bandwidth.getWarning?.(testResult)).toBe(TestWarnings.warn); 48 | }); 49 | }); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/ResultWidget/rows/bandwidth/bandwidth.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { TestResults, TestWarnings } from '../../../types'; 4 | import { Row, Typography } from '../shared'; 5 | import { Call } from '@twilio/voice-sdk'; 6 | import { round } from '../../../utils'; 7 | 8 | // These audio codecs require different amounts of bandwidth to perform well. 9 | // The bandwidth warning that is displayed to the user will have a different 10 | // threshold based on the audio codec that is chosen for the test. 11 | const codecBandwidthThresholds = { 12 | [Call.Codec.PCMU]: 100, 13 | [Call.Codec.Opus]: 40, 14 | }; 15 | 16 | const row: Row = { 17 | label: 'Bandwidth (kbps)', 18 | getValue: (testResults: TestResults) => { 19 | const value = testResults.results.preflight && testResults.results.bitrate?.averageBitrate; 20 | return typeof value === 'number' ? round(value, 0) : value; 21 | }, 22 | getWarning: (testResults: TestResults) => { 23 | if (!testResults.results.preflight) { 24 | return TestWarnings.none; 25 | } 26 | 27 | const bitrate = testResults.results.bitrate?.averageBitrate ?? 0; 28 | const codec = testResults.results.preflight?.samples.slice(-1)[0].codecName as Call.Codec; 29 | 30 | if (bitrate < codecBandwidthThresholds[codec]) { 31 | return TestWarnings.warn; 32 | } 33 | 34 | return TestWarnings.none; 35 | }, 36 | tooltipContent: { 37 | label: ( 38 | 39 | The bandwidth test performs a symmetrical bandwidth test using a loopback loop through a TURN server. 40 | 41 | ), 42 | }, 43 | }; 44 | 45 | export default row; 46 | -------------------------------------------------------------------------------- /src/ResultWidget/rows/callSid/callSid.tsx: -------------------------------------------------------------------------------- 1 | import { Row } from '../shared'; 2 | import { TestResults } from '../../../types'; 3 | 4 | const row: Row = { 5 | label: 'Call SID', 6 | getValue: (testResults: TestResults) => testResults.results.preflight?.callSid, 7 | }; 8 | 9 | export default row; 10 | -------------------------------------------------------------------------------- /src/ResultWidget/rows/expectedQuality/expectedQuality.test.tsx: -------------------------------------------------------------------------------- 1 | import expectedQuality from './expectedQuality'; 2 | import { set } from 'lodash'; 3 | import { TestResults, TestWarnings } from '../../../types'; 4 | 5 | describe('the expectedQuality row', () => { 6 | describe('getValue function', () => { 7 | it('should display the call quality and average mos', () => { 8 | let testResults = set({}, 'results.preflight.callQuality', 'excellent') as TestResults; 9 | testResults = set(testResults, 'results.preflight.stats.mos.average', 4.2365) as TestResults; 10 | expect(expectedQuality.getValue?.(testResults)).toBe('Excellent (4.24)'); 11 | }); 12 | }); 13 | 14 | describe('getWarning function', () => { 15 | it('should return TestWarnings.none when there are no low-mos warnings', () => { 16 | const testResults = set({}, 'results.preflight.warnings', []) as TestResults; 17 | expect(expectedQuality.getWarning?.(testResults)).toBe(TestWarnings.none); 18 | }); 19 | 20 | it('should return TestWarnings.warn when there are low-mos warnings', () => { 21 | const testResults = set({}, 'results.preflight.warnings.[0].name', 'low-mos') as TestResults; 22 | expect(expectedQuality.getWarning?.(testResults)).toBe(TestWarnings.warn); 23 | }); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/ResultWidget/rows/expectedQuality/expectedQuality.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { TestResults, TestWarnings } from '../../../types'; 4 | import { Row, Typography } from '../shared'; 5 | import { round } from '../../../utils'; 6 | 7 | const capitalize = (word: string) => word[0].toUpperCase() + word.slice(1); 8 | 9 | const row: Row = { 10 | label: 'Expected Audio Quality (MOS)', 11 | getValue: (testResults: TestResults) => { 12 | const quality = testResults.results.preflight?.callQuality; 13 | const mos = testResults.results.preflight?.stats?.mos?.average; 14 | if (quality && mos) { 15 | return `${capitalize(quality)} (${round(mos)})`; 16 | } 17 | }, 18 | getWarning: (testResults: TestResults) => 19 | testResults.results.preflight?.warnings.some((warning) => warning.name === 'low-mos') 20 | ? TestWarnings.warn 21 | : TestWarnings.none, 22 | tooltipContent: { 23 | label: ( 24 | 25 | Expected audio quality is calculated from jitter, latency, and packet loss measured. A measure of 3.5 and above 26 | is needed for good user experience. 27 | 28 | ), 29 | }, 30 | }; 31 | 32 | export default row; 33 | -------------------------------------------------------------------------------- /src/ResultWidget/rows/index.tsx: -------------------------------------------------------------------------------- 1 | import bandwidthRow from './bandwidth/bandwidth'; 2 | import callSidRow from './callSid/callSid'; 3 | import expectedQualityRow from './expectedQuality/expectedQuality'; 4 | import jitterRow from './jitter/jitter'; 5 | import latencyRow from './latency/latency'; 6 | import mediaServersRow from './mediaServers/mediaServers'; 7 | import packetLossRow from './packetLoss/packetLoss'; 8 | import signalingServersRow from './signallingServers/signallingServers'; 9 | // import timeToConnectRow from './timeToConnect/timeToConnect'; 10 | import timeToMediaRow from './timeToMedia/timeToMedia'; 11 | import { Row } from './shared'; 12 | 13 | export const rows: Row[] = [ 14 | signalingServersRow, 15 | mediaServersRow, 16 | // timeToConnectRow, // Remove for now - not implemented in SDK 17 | timeToMediaRow, 18 | jitterRow, 19 | latencyRow, 20 | packetLossRow, 21 | bandwidthRow, 22 | expectedQualityRow, 23 | callSidRow, 24 | ]; 25 | -------------------------------------------------------------------------------- /src/ResultWidget/rows/jitter/jitter.test.ts: -------------------------------------------------------------------------------- 1 | import jitterRow from './jitter'; 2 | import { set } from 'lodash'; 3 | import { TestResults, TestWarnings } from '../../../types'; 4 | 5 | describe('the Jitter row', () => { 6 | describe('the getValue function', () => { 7 | it('should display the min, average, and max jitter', () => { 8 | const testResults = set({}, 'results.preflight.stats.jitter', { 9 | min: 0.492876, 10 | average: 3.2345, 11 | max: 12, 12 | }) as TestResults; 13 | expect(jitterRow.getValue(testResults)).toBe('0.49 / 3.23 / 12'); 14 | }); 15 | 16 | it('should return undefined when jitter is not available', () => { 17 | const testResults = set({}, 'results', {}) as TestResults; 18 | // eslint-disable-next-line 19 | expect(jitterRow.getValue(testResults)).toBeUndefined; 20 | }); 21 | }); 22 | 23 | describe('the getWarning function', () => { 24 | it('should return TestWarnings.none when there are no high-jitter warnings', () => { 25 | const testResults = set({}, 'results.preflight.warnings', []) as TestResults; 26 | expect(jitterRow.getWarning?.(testResults)).toBe(TestWarnings.none); 27 | }); 28 | 29 | it('should return TestWarnings.warn when there are high-jitter warnings', () => { 30 | const testResults = set({}, 'results.preflight.warnings.[0].name', 'high-jitter') as TestResults; 31 | expect(jitterRow.getWarning?.(testResults)).toBe(TestWarnings.warn); 32 | }); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/ResultWidget/rows/jitter/jitter.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { TestResults, TestWarnings } from '../../../types'; 4 | import { round } from '../../../utils'; 5 | import { Row, Typography } from '../shared'; 6 | 7 | const row: Row = { 8 | label: 'Jitter min/avg/max', 9 | getValue: (testResults: TestResults) => { 10 | const jitter = testResults.results.preflight?.stats?.jitter; 11 | 12 | if (jitter) { 13 | const { min, average, max } = jitter; 14 | return `${round(min)} / ${round(average)} / ${round(max)}`; 15 | } 16 | }, 17 | getWarning: (testResults: TestResults) => 18 | testResults.results.preflight?.warnings.some((warning) => warning.name === 'high-jitter') 19 | ? TestWarnings.warn 20 | : TestWarnings.none, 21 | tooltipContent: { 22 | label: ( 23 | 24 | Jitter is a measure of incoming audio packets time variance. A high value for jitter results in deterioration of 25 | sound quality and may introduce unacceptable latency. 26 | 27 | ), 28 | }, 29 | }; 30 | 31 | export default row; 32 | -------------------------------------------------------------------------------- /src/ResultWidget/rows/latency/latency.test.ts: -------------------------------------------------------------------------------- 1 | import latencyRow from './latency'; 2 | import { set } from 'lodash'; 3 | import { TestResults, TestWarnings } from '../../../types'; 4 | 5 | describe('the latencyRow', () => { 6 | describe('the getValue function', () => { 7 | it('should display the min, average, and max latency', () => { 8 | const testResults = set({}, 'results.preflight.stats.rtt', { 9 | min: 7.145, 10 | average: 12.56972, 11 | max: 30.98, 12 | }) as TestResults; 13 | expect(latencyRow.getValue(testResults)).toBe('7 / 13 / 31'); 14 | }); 15 | 16 | it('should return undefined when latency is not available', () => { 17 | const testResults = set({}, 'results', {}) as TestResults; 18 | // eslint-disable-next-line 19 | expect(latencyRow.getValue(testResults)).toBeUndefined; 20 | }); 21 | }); 22 | describe('the getWarning function', () => { 23 | it('should return TestWarnings.none when there are no high-rtt warnings', () => { 24 | const testResults = set({}, 'results.preflight.warnings', []) as TestResults; 25 | expect(latencyRow.getWarning?.(testResults)).toBe(TestWarnings.none); 26 | }); 27 | 28 | it('should return TestWarnings.warn when there are high-rtt warnings', () => { 29 | const testResults = set({}, 'results.preflight.warnings.[0].name', 'high-rtt') as TestResults; 30 | expect(latencyRow.getWarning?.(testResults)).toBe(TestWarnings.warn); 31 | }); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /src/ResultWidget/rows/latency/latency.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { TestResults, TestWarnings } from '../../../types'; 4 | import { Row, Typography } from '../shared'; 5 | import { round } from '../../../utils'; 6 | 7 | const row: Row = { 8 | label: 'Latency (ms) min/avg/max', 9 | getValue: (testResults: TestResults) => { 10 | const latency = testResults.results.preflight?.stats?.rtt; 11 | 12 | if (latency) { 13 | const { min, average, max } = latency; 14 | return `${round(min, 0)} / ${round(average, 0)} / ${round(max, 0)}`; 15 | } 16 | }, 17 | getWarning: (testResults: TestResults) => 18 | testResults.results.preflight?.warnings.some((warning) => warning.name === 'high-rtt') 19 | ? TestWarnings.warn 20 | : TestWarnings.none, 21 | tooltipContent: { 22 | label: ( 23 | 24 | Latency is the time a packet of data takes from sender to receiver. High latency leads to poor user experience 25 | as speakers unknowingly begin to talk over each other. Latency is usually attributed to slow or overloaded 26 | networks 27 | 28 | ), 29 | }, 30 | }; 31 | 32 | export default row; 33 | -------------------------------------------------------------------------------- /src/ResultWidget/rows/mediaServers/mediaServers.test.ts: -------------------------------------------------------------------------------- 1 | import mediaServersRow from './mediaServers'; 2 | import { set } from 'lodash'; 3 | import { TestResults, TestWarnings } from '../../../types'; 4 | 5 | describe('the mediaServers row', () => { 6 | describe('the getValue function', () => { 7 | it('return "No" when the error code is 31003', () => { 8 | const testResults = set({}, 'errors.preflight.code', 31003) as TestResults; 9 | expect(mediaServersRow.getValue(testResults)).toBe('No'); 10 | }); 11 | 12 | it('should return "Did not run" when there are no bytes received and no error', () => { 13 | let testResults = set({}, 'errors', {}) as TestResults; 14 | testResults = set(testResults, 'results.preflight.samples[1].bytesReceived', 0); 15 | expect(mediaServersRow.getValue(testResults)).toBe('Did not run'); 16 | }); 17 | 18 | it('should return "Yes" when there is an error, and the last sample has more than 0 bytesReceived', () => { 19 | let testResults = set({}, 'errors.preflight.latestSample.bytesReceived', 1) as TestResults; 20 | testResults = set(testResults, 'results.preflight', {}); 21 | expect(mediaServersRow.getValue(testResults)).toBe('Yes'); 22 | }); 23 | 24 | it('should return "Yes (TURN)" when bytes are received and isTurnRequired is true', () => { 25 | let testResults = set({}, 'errors', {}) as TestResults; 26 | testResults = set(testResults, 'results.preflight.samples[1].bytesReceived', 1); 27 | testResults = set(testResults, 'results.preflight.isTurnRequired', true); 28 | expect(mediaServersRow.getValue(testResults)).toBe('Yes (TURN)'); 29 | }); 30 | 31 | it('should return "Yes (TURN UDP)" when bytes are received and isTurnRequired is true and relayProtocol is UDP', () => { 32 | let testResults = set({}, 'errors', {}) as TestResults; 33 | testResults = set(testResults, 'results.preflight.samples[1].bytesReceived', 1); 34 | testResults = set(testResults, 'results.preflight.isTurnRequired', true); 35 | testResults = set( 36 | testResults, 37 | 'results.preflight.selectedIceCandidatePairStats.localCandidate.relayProtocol', 38 | 'udp' 39 | ); 40 | expect(mediaServersRow.getValue(testResults)).toBe('Yes (TURN UDP)'); 41 | }); 42 | 43 | it('should return "Yes (TURN TCP)" when bytes are received and isTurnRequired is true and relayProtocol is TCP', () => { 44 | let testResults = set({}, 'errors', {}) as TestResults; 45 | testResults = set(testResults, 'results.preflight.samples[1].bytesReceived', 1); 46 | testResults = set(testResults, 'results.preflight.isTurnRequired', true); 47 | testResults = set( 48 | testResults, 49 | 'results.preflight.selectedIceCandidatePairStats.localCandidate.relayProtocol', 50 | 'tcp' 51 | ); 52 | expect(mediaServersRow.getValue(testResults)).toBe('Yes (TURN TCP)'); 53 | }); 54 | 55 | it('should return "Did not run" when there are no bytes received and the error is not 31003', () => { 56 | let testResults = set({}, 'errors.preflight.latestSample.bytesReceived', 0) as TestResults; 57 | testResults = set(testResults, 'results.preflight', {}); 58 | testResults = set(testResults, 'errors.preflight.code', 30000); 59 | expect(mediaServersRow.getValue(testResults)).toBe('Did not run'); 60 | }); 61 | 62 | it('should return "Yes" when the last sample has more than 0 bytesReceived', () => { 63 | let testResults = set({}, 'errors', {}) as TestResults; 64 | testResults = set(testResults, 'results.preflight.samples[1].bytesReceived', 1); 65 | expect(mediaServersRow.getValue(testResults)).toBe('Yes'); 66 | }); 67 | }); 68 | 69 | describe('getWarning function', () => { 70 | it('should return TestWarnings.error when the error code is 31003', () => { 71 | const testResults = set({}, 'errors.preflight.code', 31003) as TestResults; 72 | expect(mediaServersRow.getWarning?.(testResults)).toBe(TestWarnings.error); 73 | }); 74 | 75 | it('should return TestWarning.none when bytes are received', () => { 76 | let testResults = set({}, 'errors', {}) as TestResults; 77 | testResults = set(testResults, 'results.preflight.samples[1].bytesReceived', 1); 78 | expect(mediaServersRow.getWarning?.(testResults)).toBe(TestWarnings.none); 79 | }); 80 | 81 | it('should return TestWarnings.none when bytes are received and the error code is not 31003', () => { 82 | let testResults = set({}, 'errors.preflight.latestSample.bytesReceived', 1) as TestResults; 83 | testResults = set(testResults, 'results.preflight', {}); 84 | testResults = set(testResults, 'errors.preflight.code', 30000); 85 | expect(mediaServersRow.getWarning?.(testResults)).toBe(TestWarnings.none); 86 | }); 87 | 88 | it('should return TestWarnings.warnTurn when bytes are receved and isTurnRequired is true', () => { 89 | let testResults = set({}, 'errors', {}) as TestResults; 90 | testResults = set(testResults, 'results.preflight.samples[1].bytesReceived', 1); 91 | testResults = set(testResults, 'results.preflight.isTurnRequired', true); 92 | expect(mediaServersRow.getWarning?.(testResults)).toBe(TestWarnings.warnTurn); 93 | }); 94 | 95 | it('should return TestWarnings.warnTurnUDP when bytes are received and isTurnRequired is true and relayProtocol is udp', () => { 96 | let testResults = set({}, 'errors', {}) as TestResults; 97 | testResults = set(testResults, 'results.preflight.samples[1].bytesReceived', 1); 98 | testResults = set(testResults, 'results.preflight.isTurnRequired', true); 99 | testResults = set( 100 | testResults, 101 | 'results.preflight.selectedIceCandidatePairStats.localCandidate.relayProtocol', 102 | 'udp' 103 | ); 104 | expect(mediaServersRow.getWarning?.(testResults)).toBe(TestWarnings.warnTurnUDP); 105 | }); 106 | 107 | it('should return TestWarnings.warnTurnTCP when bytes are received and isTurnRequired is true and relayProtocol is tcp', () => { 108 | let testResults = set({}, 'errors', {}) as TestResults; 109 | testResults = set(testResults, 'results.preflight.samples[1].bytesReceived', 1); 110 | testResults = set(testResults, 'results.preflight.isTurnRequired', true); 111 | testResults = set( 112 | testResults, 113 | 'results.preflight.selectedIceCandidatePairStats.localCandidate.relayProtocol', 114 | 'tcp' 115 | ); 116 | expect(mediaServersRow.getWarning?.(testResults)).toBe(TestWarnings.warnTurnTCP); 117 | }); 118 | 119 | it('should return TestWarnings.none when no bytes are received and there is no error', () => { 120 | let testResults = set({}, 'errors', {}) as TestResults; 121 | testResults = set(testResults, 'results.preflight.samples[1].bytesReceived', 0); 122 | expect(mediaServersRow.getWarning?.(testResults)).toBe(TestWarnings.none); 123 | }); 124 | 125 | it('should return TestWarnings.none when no bytes are received and the error code is not 31003', () => { 126 | let testResults = set({}, 'errors.preflight.latestSample.bytesReceived', 0) as TestResults; 127 | testResults = set(testResults, 'results.preflight', {}); 128 | testResults = set(testResults, 'results.preflightcode', 30000); 129 | expect(mediaServersRow.getWarning?.(testResults)).toBe(TestWarnings.none); 130 | }); 131 | }); 132 | }); 133 | -------------------------------------------------------------------------------- /src/ResultWidget/rows/mediaServers/mediaServers.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { TestResults, TestWarnings } from '../../../types'; 4 | import { Link, Row, Typography } from '../shared'; 5 | 6 | const hasError = (testResults: TestResults) => { 7 | const code = testResults.errors.preflight?.code; 8 | return code === 31003; 9 | }; 10 | 11 | const row: Row = { 12 | label: 'Media Servers Reachable', 13 | getValue: (testResults: TestResults) => { 14 | if (hasError(testResults)) { 15 | return 'No'; 16 | } 17 | 18 | if ( 19 | testResults.results.preflight?.samples?.slice(-1)[0].bytesReceived || 20 | testResults.errors.preflight?.latestSample?.bytesReceived 21 | ) { 22 | if (testResults.results.preflight?.isTurnRequired) { 23 | const turnProtocol = testResults.results.preflight?.selectedIceCandidatePairStats?.localCandidate.relayProtocol; 24 | if (turnProtocol === 'tcp') { 25 | return 'Yes (TURN TCP)'; 26 | } 27 | 28 | if (turnProtocol === 'udp') { 29 | return 'Yes (TURN UDP)'; 30 | } 31 | 32 | return 'Yes (TURN)'; 33 | } 34 | return 'Yes'; 35 | } 36 | 37 | return 'Did not run'; 38 | }, 39 | getWarning: (testResults: TestResults) => { 40 | if (hasError(testResults)) { 41 | return TestWarnings.error; 42 | } 43 | 44 | if ( 45 | testResults.results.preflight?.samples?.slice(-1)[0].bytesReceived || 46 | testResults.errors.preflight?.latestSample?.bytesReceived 47 | ) { 48 | if (testResults.results.preflight?.isTurnRequired) { 49 | const turnProtocol = testResults.results.preflight?.selectedIceCandidatePairStats?.localCandidate.relayProtocol; 50 | if (turnProtocol === 'tcp') { 51 | return TestWarnings.warnTurnTCP; 52 | } 53 | 54 | if (turnProtocol === 'udp') { 55 | return TestWarnings.warnTurnUDP; 56 | } 57 | 58 | return TestWarnings.warnTurn; 59 | } 60 | return TestWarnings.none; 61 | } 62 | 63 | return TestWarnings.none; 64 | }, 65 | tooltipContent: { 66 | label: ( 67 | 68 | Tests connectivity to the Media servers. See{' '} 69 | 70 | media connectivity requirements 71 | 72 | for more information. Unreachable media servers cause the call to drop prematurely and could cause one way audio 73 | type issues. 74 | 75 | ), 76 | [TestWarnings.error]: ( 77 | 78 | The media server could not be reached. Ensure your device has internet connectivity, and any firewalls allow 79 | access to Twilio’s{' '} 80 | 81 | Media servers 82 | 83 | 84 | ), 85 | }, 86 | }; 87 | 88 | export default row; 89 | -------------------------------------------------------------------------------- /src/ResultWidget/rows/packetLoss/packetLoss.test.ts: -------------------------------------------------------------------------------- 1 | import packetLossRow from './packetLoss'; 2 | import { set } from 'lodash'; 3 | import { TestResults, TestWarnings } from '../../../types'; 4 | 5 | describe('the packetLoss row', () => { 6 | describe('the getValue function', () => { 7 | it('should display the packet loss as a percentage', () => { 8 | const testResults = set({}, 'results.preflight.totals.packetsLostFraction', 0) as TestResults; 9 | expect(packetLossRow.getValue(testResults)).toBe('0%'); 10 | }); 11 | 12 | it('should round to two decimal points', () => { 13 | const testResults = set({}, 'results.preflight.totals.packetsLostFraction', 1.7264) as TestResults; 14 | expect(packetLossRow.getValue(testResults)).toBe('1.73%'); 15 | }); 16 | 17 | it('should return undefined when packetLossFraction is undefined', () => { 18 | const testResults = set({}, 'results.preflight.totals.packetsLostFraction', undefined) as TestResults; 19 | expect(packetLossRow.getValue(testResults)).toBe(undefined); 20 | }); 21 | }); 22 | 23 | describe('the getWarning function', () => { 24 | it('should return TestWarnings.none when there are no high-packet-loss warnings', () => { 25 | const testResults = set({}, 'results.preflight.warnings', []) as TestResults; 26 | expect(packetLossRow.getWarning?.(testResults)).toBe(TestWarnings.none); 27 | }); 28 | 29 | it('should return TestWarnings.warn when there are high-packet-loss warnings', () => { 30 | const testResults = set({}, 'results.preflight.warnings.[0].name', 'high-packet-loss') as TestResults; 31 | expect(packetLossRow.getWarning?.(testResults)).toBe(TestWarnings.warn); 32 | }); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/ResultWidget/rows/packetLoss/packetLoss.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { TestResults, TestWarnings } from '../../../types'; 4 | import { Row, Typography } from '../shared'; 5 | import { round } from '../../../utils'; 6 | 7 | const row: Row = { 8 | label: 'Packet Loss', 9 | getValue: (testResults: TestResults) => { 10 | const packetLoss = testResults.results.preflight?.totals?.packetsLostFraction; 11 | if (typeof packetLoss !== 'undefined') { 12 | return `${round(packetLoss)}%`; 13 | } 14 | }, 15 | getWarning: (testResults: TestResults) => 16 | testResults.results.preflight?.warnings.some((warning) => warning.name === 'high-packet-loss') 17 | ? TestWarnings.warn 18 | : TestWarnings.none, 19 | tooltipContent: { 20 | label: ( 21 | 22 | The percentage of packets lost. High packet loss results in missing audio fragments leading to unintelligible 23 | speech. Packet loss is usually caused by overloaded routers 24 | 25 | ), 26 | }, 27 | }; 28 | 29 | export default row; 30 | -------------------------------------------------------------------------------- /src/ResultWidget/rows/shared.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { TestWarnings, TestResults } from '../../types'; 3 | import { Link as LinkImpl, Typography as TypographyImpl } from '@material-ui/core'; 4 | import { styled } from '@material-ui/core/styles'; 5 | 6 | export type RowLabel = 7 | | 'Signalling Servers Reachable' 8 | | 'Media Servers Reachable' 9 | | 'Time To Connect' 10 | | 'Time to Media' 11 | | 'Jitter min/avg/max' 12 | | 'Latency (ms) min/avg/max' 13 | | 'Packet Loss' 14 | | 'Bandwidth (kbps)' 15 | | 'Expected Audio Quality (MOS)' 16 | | 'Call SID'; 17 | 18 | export type Row = { 19 | label: RowLabel; 20 | getValue(testResults: TestResults): string | number | undefined; 21 | getWarning?(testResults: TestResults): TestWarnings; 22 | tooltipContent?: TooltipContent; 23 | }; 24 | 25 | export type TooltipContent = { 26 | label: React.ReactNode; 27 | [TestWarnings.warn]?: React.ReactNode; 28 | [TestWarnings.error]?: React.ReactNode; 29 | [TestWarnings.warnTurn]?: React.ReactNode; 30 | [TestWarnings.warnTurnTCP]?: React.ReactNode; 31 | [TestWarnings.warnTurnUDP]?: React.ReactNode; 32 | }; 33 | 34 | export const Link = ({ children, href }: { children: string; href: string }) => ( 35 | 36 | {children} 37 | 38 | ); 39 | 40 | const StyledTypography = styled(TypographyImpl)({ 41 | '&:not(:last-child)': { 42 | marginBottom: '0.6em', 43 | }, 44 | }); 45 | 46 | export const Typography = ({ children }: { children: React.ReactNode }) => ( 47 | {children} 48 | ); 49 | -------------------------------------------------------------------------------- /src/ResultWidget/rows/signallingServers/signallingServers.test.ts: -------------------------------------------------------------------------------- 1 | import signallingServersRow from './signallingServers'; 2 | import { set } from 'lodash'; 3 | import { TestResults, TestWarnings } from '../../../types'; 4 | 5 | describe('the signallingServers row', () => { 6 | describe('getValue function', () => { 7 | it('should return "No" when the error code is 31901', () => { 8 | const testResult = set({}, 'errors.preflight.code', 31901) as TestResults; 9 | expect(signallingServersRow.getValue(testResult)).toBe('No'); 10 | }); 11 | 12 | it('should return "No" when the error code is 31005', () => { 13 | const testResult = set({}, 'errors.preflight.code', 31005) as TestResults; 14 | expect(signallingServersRow.getValue(testResult)).toBe('No'); 15 | }); 16 | 17 | it('should return "No" when the error code is 31000', () => { 18 | const testResult = set({}, 'errors.preflight.code', 31000) as TestResults; 19 | expect(signallingServersRow.getValue(testResult)).toBe('No'); 20 | }); 21 | 22 | it('should return "Yes" if the preflight test completed successfully', () => { 23 | const testResult = set({}, 'results.preflight', {}) as TestResults; 24 | expect(signallingServersRow.getValue(testResult)).toBe('Yes'); 25 | }); 26 | 27 | it('should return "Yes" if the preflight test emits the "connected" event', () => { 28 | const baseResult = set({}, 'results', {}); 29 | const testResult = set(baseResult, 'errors.preflight.hasConnected', true) as TestResults; 30 | expect(signallingServersRow.getValue(testResult)).toBe('Yes'); 31 | }); 32 | 33 | it('should return "Did not run" in all orhter cases', () => { 34 | const baseResult = set({}, 'results', {}); 35 | const testResult = set(baseResult, 'errors', {}) as TestResults; 36 | expect(signallingServersRow.getValue(testResult)).toBe('Did not run'); 37 | }); 38 | }); 39 | describe('the getWarning function', () => { 40 | it('should return TestWarnings.error when the error code is 31901', () => { 41 | const testResult = set({}, 'errors.preflight.code', 31901) as TestResults; 42 | expect(signallingServersRow.getWarning?.(testResult)).toBe(TestWarnings.error); 43 | }); 44 | 45 | it('should return TestWarnings.error when the error code is 31005', () => { 46 | const testResult = set({}, 'errors.preflight.code', 31005) as TestResults; 47 | expect(signallingServersRow.getWarning?.(testResult)).toBe(TestWarnings.error); 48 | }); 49 | 50 | it('should return TestWarnings.none in all other cases', () => { 51 | const testResult = set({}, 'results.preflight', {}) as TestResults; 52 | expect(signallingServersRow.getWarning?.(testResult)).toBe(TestWarnings.none); 53 | }); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /src/ResultWidget/rows/signallingServers/signallingServers.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { TestResults, TestWarnings } from '../../../types'; 4 | import { Link, Row, Typography } from '../shared'; 5 | 6 | const hasError = (testResults: TestResults) => { 7 | const code = testResults.errors?.preflight?.code; 8 | return code === 31901 || code === 31005 || code === 31000; 9 | }; 10 | 11 | const row: Row = { 12 | label: 'Signalling Servers Reachable', 13 | getValue: (testResults: TestResults) => { 14 | if (hasError(testResults)) { 15 | return 'No'; 16 | } 17 | 18 | if (testResults.results.preflight || testResults.errors.preflight?.hasConnected) { 19 | return 'Yes'; 20 | } 21 | 22 | return 'Did not run'; 23 | }, 24 | getWarning: (testResults: TestResults) => { 25 | if (hasError(testResults)) { 26 | return TestWarnings.error; 27 | } 28 | 29 | return TestWarnings.none; 30 | }, 31 | tooltipContent: { 32 | label: ( 33 | 34 | Tests connectivity to the Signalling servers. See{' '} 35 | 36 | connectivity requirements 37 | {' '} 38 | for more information. Calls cannot be established without connectivity to the signalling servers 39 | 40 | ), 41 | [TestWarnings.error]: ( 42 | 43 | The Signalling server could not be reached. Ensure your device has internet connectivity, DNS is configured 44 | correctly, and any firewalls allow access to Twilio’s{' '} 45 | 46 | Signalling Servers 47 | 48 | 49 | ), 50 | }, 51 | }; 52 | 53 | export default row; 54 | -------------------------------------------------------------------------------- /src/ResultWidget/rows/timeToConnect/timeToConnect.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { TestResults, TestWarnings } from '../../../types'; 4 | import { Row, Typography } from '../shared'; 5 | 6 | const row: Row = { 7 | label: 'Time To Connect', 8 | getValue: (testResults: TestResults) => 'N/A', 9 | getWarning: (testResults: TestResults) => TestWarnings.none, 10 | tooltipContent: { 11 | label: ( 12 | 13 | The time it takes for signalling to complete. The lower the better. A high value indicates latency issues in the 14 | connection between the endpoints. 15 | 16 | ), 17 | [TestWarnings.warn]: ( 18 | 19 | High time to media results in a bad user experience when the call is first picked up. Run the test a few times 20 | to ensure this is not a glitch in the network 21 | 22 | ), 23 | }, 24 | }; 25 | 26 | export default row; 27 | -------------------------------------------------------------------------------- /src/ResultWidget/rows/timeToMedia/timeToMedia.test.ts: -------------------------------------------------------------------------------- 1 | import timeToMediaRow from './timeToMedia'; 2 | import { set } from 'lodash'; 3 | import { TestResults, TestWarnings } from '../../../types'; 4 | 5 | describe('the timeToMedia row', () => { 6 | describe('the getWarning function', () => { 7 | it('should return none when the duration is less than 1800', () => { 8 | const results = set({}, 'results.preflight.networkTiming.peerConnection.duration', 1799) as TestResults; 9 | expect(timeToMediaRow.getWarning?.(results)).toBe(TestWarnings.none); 10 | }); 11 | 12 | it('should return warn when the duration is 1800 or more', () => { 13 | const results = set({}, 'results.preflight.networkTiming.peerConnection.duration', 1800) as TestResults; 14 | expect(timeToMediaRow.getWarning?.(results)).toBe(TestWarnings.warn); 15 | }); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/ResultWidget/rows/timeToMedia/timeToMedia.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { TestResults, TestWarnings } from '../../../types'; 4 | import { Row, Typography } from '../shared'; 5 | 6 | const row: Row = { 7 | label: 'Time to Media', 8 | getValue: (testResults: TestResults) => testResults.results.preflight?.networkTiming?.peerConnection?.duration, 9 | getWarning: (testResults: TestResults) => 10 | (testResults.results.preflight?.networkTiming?.peerConnection?.duration ?? 0) < 1800 // TODO: Look for high-pc-connect-duration in report.warnings when available 11 | ? TestWarnings.none 12 | : TestWarnings.warn, 13 | tooltipContent: { 14 | label: ( 15 | 16 | The time it takes for the media to flow between the caller and callee after the callee picks up. The lower the 17 | better. A high value indicates latency issues in the connection between the endpoints. 18 | 19 | ), 20 | [TestWarnings.warn]: ( 21 | 22 | High time to media results in a bad user experience when the call is first picked up. Run the test a few times 23 | to ensure this is not a glitch in the network. 24 | 25 | ), 26 | }, 27 | }; 28 | 29 | export default row; 30 | -------------------------------------------------------------------------------- /src/SummaryWidget/SummaryWidget.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { mount } from 'enzyme'; 3 | import SummaryWidget from './SummaryWidget'; 4 | 5 | const results = [ 6 | { 7 | edge: 'ashburn', 8 | results: { 9 | preflight: { 10 | callQuality: 'Good', 11 | stats: { 12 | mos: { 13 | average: 4, 14 | }, 15 | }, 16 | }, 17 | }, 18 | }, 19 | { 20 | edge: 'dublin', 21 | results: { 22 | preflight: { 23 | callQuality: 'Great', 24 | stats: { 25 | mos: { 26 | average: 5, 27 | }, 28 | }, 29 | }, 30 | }, 31 | }, 32 | ]; 33 | 34 | describe('the SummaryWidget component', () => { 35 | it('should choose the edge with the highest mos score and display it', () => { 36 | const wrapper = mount(); 37 | expect(wrapper.at(0).text()).toBe('Expected Call Quality: Great (5)Recommended Edge Location: Dublin'); 38 | }); 39 | 40 | it('should not render when "results" is undefined', () => { 41 | const wrapper = mount(); 42 | expect(wrapper.childAt(0).exists()).toBe(false); 43 | }); 44 | 45 | it('should not render when the "results" array has no results', () => { 46 | const wrapper = mount(); 47 | expect(wrapper.childAt(0).exists()).toBe(false); 48 | }); 49 | 50 | it('should not render recommended result if there is only one result', () => { 51 | const wrapper = mount(); 52 | expect(wrapper.at(0).text()).toBe('Expected Call Quality: Great (5)'); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/SummaryWidget/SummaryWidget.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Alert from '../common/Alert/Alert'; 3 | import expectedQualityRow from '../ResultWidget/rows/expectedQuality/expectedQuality'; 4 | import { getBestEdge, getEdgeName } from '../utils'; 5 | import { TestResults } from '../types'; 6 | 7 | export default function SummaryWidget({ results }: { results?: TestResults[] }) { 8 | if (!results) return null; 9 | 10 | const bestEdge = getBestEdge(results); 11 | 12 | if (bestEdge) { 13 | const bestEdgeQuality = expectedQualityRow.getValue(bestEdge); 14 | const bestEdgeName = getEdgeName(bestEdge); 15 | 16 | return ( 17 |
18 | 19 | 20 | Expected Call Quality: {bestEdgeQuality} 21 | 22 | 23 | {results.length > 1 && ( 24 | 25 | 26 | Recommended Edge Location: {bestEdgeName} 27 | 28 | 29 | )} 30 |
31 | ); 32 | } else { 33 | return null; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/common/Alert/Alert.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Alert from './Alert'; 3 | import { shallow } from 'enzyme'; 4 | 5 | describe('the Alert component', () => { 6 | it('should render the "success" variant correctly', () => { 7 | const wrapper = shallow( 8 | 9 | Test Info Content 10 | 11 | ); 12 | 13 | expect(wrapper).toMatchSnapshot(); 14 | }); 15 | 16 | it('should render the "warning" variant correctly', () => { 17 | const wrapper = shallow( 18 | 19 | Test Warning Content 20 | 21 | ); 22 | 23 | expect(wrapper).toMatchSnapshot(); 24 | }); 25 | 26 | it('should render the "error" variant correctly', () => { 27 | const wrapper = shallow( 28 | 29 | Test Error Content 30 | 31 | ); 32 | 33 | expect(wrapper).toMatchSnapshot(); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/common/Alert/Alert.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import clsx from 'clsx'; 3 | import CheckIcon from '@material-ui/icons/CheckCircleOutline'; 4 | import ErrorIcon from '@material-ui/icons/ErrorOutline'; 5 | import WarningIcon from '@material-ui/icons/ReportProblemOutlined'; 6 | import { darken, lighten, makeStyles, Theme } from '@material-ui/core/styles'; 7 | 8 | type AlertProps = { 9 | children: React.ReactNode; 10 | variant: 'success' | 'warning' | 'error'; 11 | }; 12 | 13 | const useStyles = makeStyles((theme: Theme) => { 14 | const getBackgroundColor = theme.palette.type === 'light' ? lighten : darken; 15 | 16 | return { 17 | container: { 18 | display: 'flex', 19 | padding: '0.85em', 20 | borderRadius: theme.shape.borderRadius, 21 | '&:not(:last-child)': { 22 | marginBottom: '0.8em', 23 | }, 24 | '& svg': { 25 | margin: '0 0.6em 0 0.3em', 26 | padding: '1px', 27 | }, 28 | }, 29 | contentContainer: { 30 | display: 'flex', 31 | flexDirection: 'column', 32 | justifyContent: 'center', 33 | }, 34 | success: { 35 | backgroundColor: getBackgroundColor(theme.palette.success.main, 0.9), 36 | '& svg': { 37 | fill: theme.palette.success.main, 38 | }, 39 | }, 40 | warning: { 41 | backgroundColor: getBackgroundColor(theme.palette.warning.main, 0.9), 42 | '& svg': { 43 | fill: theme.palette.warning.main, 44 | }, 45 | }, 46 | error: { 47 | backgroundColor: getBackgroundColor(theme.palette.error.main, 0.9), 48 | '& svg': { 49 | fill: theme.palette.error.main, 50 | }, 51 | }, 52 | }; 53 | }); 54 | 55 | export default function Alert({ variant, children }: AlertProps) { 56 | const classes = useStyles(); 57 | 58 | return ( 59 |
60 | {variant === 'success' && } 61 | {variant === 'warning' && } 62 | {variant === 'error' && } 63 |
{children}
64 |
65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /src/common/Alert/__snapshots__/Alert.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`the Alert component should render the "error" variant correctly 1`] = ` 4 |
7 | 8 |
11 | 12 | Test Error Content 13 | 14 |
15 |
16 | `; 17 | 18 | exports[`the Alert component should render the "success" variant correctly 1`] = ` 19 |
22 | 23 |
26 | 27 | Test Info Content 28 | 29 |
30 |
31 | `; 32 | 33 | exports[`the Alert component should render the "warning" variant correctly 1`] = ` 34 |
37 | 38 |
41 | 42 | Test Warning Content 43 | 44 |
45 |
46 | `; 47 | -------------------------------------------------------------------------------- /src/common/ProgressBar/ProgressBar.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ProgressBar from './ProgressBar'; 3 | import { render } from '@testing-library/react'; 4 | 5 | describe('the ProgressBar component', () => { 6 | it('should update the style object asynchronously', (done) => { 7 | const { container } = render(); 8 | const progressBarEl = container.querySelector('.makeStyles-progress-2') as HTMLDivElement; 9 | expect(progressBarEl.style.transition).toBe(''); 10 | expect(progressBarEl.style.right).toBe(''); 11 | expect(progressBarEl.parentElement!.style.margin).toBe('0px'); 12 | 13 | window.requestAnimationFrame(() => { 14 | expect(progressBarEl.style.transition).toBe('right 10s linear'); 15 | expect(progressBarEl.style.right).toBe('50%'); 16 | expect(progressBarEl.parentElement!.style.margin).toBe('0px'); 17 | done(); 18 | }); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/common/ProgressBar/ProgressBar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useEffect } from 'react'; 2 | import { fade } from '@material-ui/core/styles/colorManipulator'; 3 | import { makeStyles, Theme } from '@material-ui/core/styles'; 4 | 5 | const useStyles = makeStyles((theme: Theme) => ({ 6 | container: { 7 | position: 'relative', 8 | margin: '1em', 9 | height: '4px', 10 | '& div': { 11 | position: 'absolute', 12 | top: 0, 13 | bottom: 0, 14 | left: 0, 15 | }, 16 | }, 17 | progress: { 18 | background: theme.palette.secondary.main, 19 | right: '100%', 20 | }, 21 | background: { 22 | right: 0, 23 | background: fade(theme.palette.secondary.main, 0.2), 24 | }, 25 | })); 26 | 27 | interface ProgressBarProps { 28 | duration: number; 29 | position: number; 30 | style?: { [key: string]: string }; 31 | } 32 | 33 | export default function ProgressBar({ duration, position, style }: ProgressBarProps) { 34 | const classes = useStyles(); 35 | const progressBarRef = useRef(null); 36 | 37 | useEffect(() => { 38 | const el = progressBarRef.current; 39 | if (el) { 40 | window.requestAnimationFrame(() => { 41 | // We set these values asynchronously so that the browser can recognize the change in the 'right' value. 42 | // Without this, the progress bar would instantly snap to the designated position. 43 | el.style.transition = `right ${duration}s linear`; 44 | el.style.right = `${String(100 - position)}%`; 45 | }); 46 | } 47 | }, [duration, position]); 48 | 49 | return ( 50 |
51 |
52 |
53 |
54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import { Edge } from './types'; 2 | import { Call } from '@twilio/voice-sdk'; 3 | import { default as appInfo } from '../package.json'; 4 | 5 | export const APP_NAME = appInfo.name; 6 | 7 | export const DEFAULT_EDGES: Edge[] = ['roaming']; 8 | 9 | export const DEFAULT_CODEC_PREFERENCES: Call.Codec[] = [Call.Codec.PCMU, Call.Codec.Opus]; 10 | 11 | export const LOG_LEVEL = process.env.NODE_ENV === 'development' ? 'debug' : 'error'; 12 | 13 | export const MAX_SELECTED_EDGES = 3; 14 | 15 | export const MIN_SELECTED_EDGES = 1; 16 | 17 | export const AUDIO_LEVEL_THRESHOLD = 200; 18 | 19 | export const AUDIO_LEVEL_STANDARD_DEVIATION_THRESHOLD = AUDIO_LEVEL_THRESHOLD * 0.05; // 5% threshold 20 | 21 | export const INPUT_TEST_DURATION = 20000; 22 | 23 | export const RECORD_DURATION = 4000; 24 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | import { CssBaseline, ThemeProvider } from '@material-ui/core'; 5 | import theme from './theme'; 6 | 7 | ReactDOM.render( 8 | 9 | 10 | 11 | 12 | 13 | , 14 | document.getElementById('root') 15 | ); 16 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/setupProxy.js: -------------------------------------------------------------------------------- 1 | const { createProxyMiddleware } = require('http-proxy-middleware'); 2 | module.exports = function (app) { 3 | app.use( 4 | '/app', 5 | createProxyMiddleware({ 6 | target: process.env.PROXY_URL, 7 | changeOrigin: true, 8 | }) 9 | ); 10 | }; 11 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | import Enzyme from 'enzyme'; 7 | // @ts-ignore - We don't need type definitions for the adapter 8 | import Adapter from 'enzyme-adapter-react-16'; 9 | 10 | Enzyme.configure({ adapter: new Adapter() }); 11 | -------------------------------------------------------------------------------- /src/theme.ts: -------------------------------------------------------------------------------- 1 | import { createMuiTheme } from '@material-ui/core'; 2 | 3 | declare module '@material-ui/core/styles/createMuiTheme' { 4 | interface Theme { 5 | sidebarWidth: number; 6 | sidebarMobileHeight: number; 7 | } 8 | 9 | // allow configuration using `createMuiTheme` 10 | interface ThemeOptions { 11 | sidebarWidth?: number; 12 | sidebarMobileHeight?: number; 13 | } 14 | } 15 | 16 | export default createMuiTheme({ 17 | palette: { 18 | primary: { 19 | main: '#0D122B', 20 | }, 21 | secondary: { 22 | main: '#027AC5', 23 | }, 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { MediaConnectionBitrateTest } from '@twilio/rtc-diagnostics'; 2 | import { PreflightTest, TwilioError } from '@twilio/voice-sdk'; 3 | import { DiagnosticError } from '@twilio/rtc-diagnostics'; 4 | import RTCSample from '@twilio/voice-sdk/es5/twilio/rtc/sample'; 5 | 6 | export type NetworkTestName = 'Bitrate Test' | 'Preflight Test'; 7 | 8 | declare global { 9 | interface RTCIceServer { 10 | url: string; 11 | } 12 | } 13 | 14 | declare module '@twilio/voice-sdk' { 15 | // eslint-disable-next-line 16 | namespace TwilioError { 17 | interface TwilioError { 18 | hasConnected: boolean; 19 | latestSample: RTCSample; 20 | } 21 | } 22 | } 23 | 24 | export interface TestResults { 25 | edge: Edge; 26 | results: { 27 | bitrate?: MediaConnectionBitrateTest.Report; 28 | preflight?: PreflightTest.Report; 29 | }; 30 | errors: { 31 | bitrate?: DiagnosticError; 32 | preflight?: TwilioError.TwilioError; 33 | }; 34 | } 35 | 36 | export enum TestWarnings { 37 | none = '', 38 | warn = 'warn', 39 | error = 'error', 40 | warnTurn = 'warnTurn', 41 | warnTurnTCP = 'warnTurnTCP', 42 | warnTurnUDP = 'warnTurnUDP', 43 | } 44 | 45 | export type Edge = 46 | | 'ashburn' 47 | | 'dublin' 48 | | 'frankfurt' 49 | | 'roaming' 50 | | 'sao-paulo' 51 | | 'singapore' 52 | | 'sydney' 53 | | 'tokyo' 54 | | 'ashburn-ix' 55 | | 'london-ix' 56 | | 'frankfurt-ix' 57 | | 'san-jose-ix' 58 | | 'singapore-ix'; 59 | -------------------------------------------------------------------------------- /src/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getAudioLevelPercentage, 3 | getBestEdge, 4 | getEdgeName, 5 | getStandardDeviation, 6 | regionalizeIceUrls, 7 | round, 8 | } from './utils'; 9 | import { set } from 'lodash'; 10 | import { TestResults } from './types'; 11 | 12 | const testIceUrls: RTCIceServer[] = [ 13 | { 14 | url: 'stun:global.stun.twilio.com:3478?transport=udp', 15 | urls: 'stun:global.stun.twilio.com:3478?transport=udp', 16 | }, 17 | { 18 | url: 'turn:global.turn.twilio.com:3478?transport=tcp', 19 | urls: 'turn:global.turn.twilio.com:3478?transport=tcp', 20 | }, 21 | ]; 22 | 23 | describe('the round function', () => { 24 | it('should round to 2 decimal places by default', () => { 25 | expect(round(10.236)).toBe(10.24); 26 | expect(round(2.123)).toBe(2.12); 27 | }); 28 | 29 | it('should round to the specified number of decimal places', () => { 30 | expect(round(23.678, 0)).toBe(24); 31 | expect(round(23.378, 0)).toBe(23); 32 | expect(round(23.378, 1)).toBe(23.4); 33 | expect(round(23.378124, 3)).toBe(23.378); 34 | }); 35 | }); 36 | 37 | describe('the regionalizeIceUrl function', () => { 38 | it('should replace "global" with the provided edge location', () => { 39 | expect(regionalizeIceUrls('ashburn', testIceUrls)).toEqual([ 40 | { 41 | url: 'stun:ashburn.stun.twilio.com:3478?transport=udp', 42 | urls: 'stun:ashburn.stun.twilio.com:3478?transport=udp', 43 | }, 44 | { 45 | url: 'turn:ashburn.turn.twilio.com:3478?transport=tcp', 46 | urls: 'turn:ashburn.turn.twilio.com:3478?transport=tcp', 47 | }, 48 | ]); 49 | }); 50 | 51 | it('should replace "global" with the provided edge when "urls" property is an array', () => { 52 | const iceServers = [ 53 | { 54 | url: 'stun:global.stun.twilio.com:3478?transport=udp', 55 | urls: ['stun:global.stun.twilio.com:3478?transport=udp', 'stun:global.stun.twilio.com:443?transport=tcp'], 56 | }, 57 | ]; 58 | 59 | expect(regionalizeIceUrls('ashburn', iceServers)).toEqual([ 60 | { 61 | url: 'stun:ashburn.stun.twilio.com:3478?transport=udp', 62 | urls: ['stun:ashburn.stun.twilio.com:3478?transport=udp', 'stun:ashburn.stun.twilio.com:443?transport=tcp'], 63 | }, 64 | ]); 65 | }); 66 | 67 | it('should not replace any text when edge is "roaming"', () => { 68 | expect(regionalizeIceUrls('roaming', testIceUrls)).toEqual(testIceUrls); 69 | }); 70 | }); 71 | 72 | describe('the getEdgeName function', () => { 73 | it('should return the capitalized edge name when the selected edge is not roaming', () => { 74 | const mockResult = set({ edge: 'ashburn' }, 'results.preflight.selectedEdge', 'ashburn') as TestResults; 75 | const edgeName = getEdgeName(mockResult); 76 | expect(edgeName).toBe('Ashburn'); 77 | }); 78 | 79 | it('should display the actual edge name when the selected edge is roaming', () => { 80 | let mockResult = set({ edge: 'roaming' }, 'results.preflight.selectedEdge', 'roaming') as TestResults; 81 | mockResult = set(mockResult, 'results.preflight.edge', 'ashburn'); 82 | const edgeName = getEdgeName(mockResult); 83 | expect(edgeName).toBe('Roaming - Ashburn'); 84 | }); 85 | }); 86 | 87 | describe('the getBestEdge function', () => { 88 | it('should return the capitalized edge name when the selected edge is not roaming', () => { 89 | const mockResult1 = set({ edge: 'ashburn' }, 'results.preflight.stats.mos.average', 5) as TestResults; 90 | const mockResult2 = set({ edge: 'roaming' }, 'results.preflight.stats.mos.average', 3) as TestResults; 91 | const bestEdge = getBestEdge([mockResult1, mockResult2]); 92 | expect(bestEdge).toEqual(mockResult1); 93 | }); 94 | }); 95 | 96 | describe('the getAudioLevelPercentage function', () => { 97 | [ 98 | { inputLevel: 0, outputPercentage: 0 }, 99 | { inputLevel: 1, outputPercentage: 0.5 }, 100 | { inputLevel: 200, outputPercentage: 100 }, 101 | { inputLevel: 30, outputPercentage: 15 }, 102 | ].forEach(({ inputLevel, outputPercentage }) => { 103 | it(`should return ${outputPercentage} if input level is ${inputLevel}`, () => { 104 | expect(getAudioLevelPercentage(inputLevel)).toEqual(outputPercentage); 105 | }); 106 | }); 107 | }); 108 | 109 | describe('the getStandardDeviation function', () => { 110 | [ 111 | { stdDev: 0, values: [0, 0, 0, 0] }, 112 | { stdDev: 0, values: [] }, 113 | { stdDev: 12.03, values: [30, 20, 10, 10, 43, 32] }, 114 | ].forEach(({ stdDev, values }) => { 115 | it(`should return ${stdDev}`, () => { 116 | expect(getStandardDeviation(values)).toEqual(stdDev); 117 | }); 118 | }); 119 | }); 120 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { maxBy } from 'lodash'; 2 | import { Edge, TestResults } from './types'; 3 | import { AUDIO_LEVEL_THRESHOLD } from './constants'; 4 | 5 | export function getAudioLevelPercentage(level: number) { 6 | return (level * 100) / AUDIO_LEVEL_THRESHOLD; // 0 to 100 7 | } 8 | 9 | export function getStandardDeviation(values: number[]): number { 10 | // Same method used in client sdks 11 | // https://github.com/twilio/twilio-client.js/blob/master/lib/twilio/statsMonitor.ts#L88 12 | 13 | if (values.length <= 0) { 14 | return 0; 15 | } 16 | 17 | const valueAverage: number = 18 | values.reduce((partialSum: number, value: number) => partialSum + value, 0) / values.length; 19 | 20 | const diffSquared: number[] = values.map((value: number) => Math.pow(value - valueAverage, 2)); 21 | 22 | const stdDev: number = Math.sqrt( 23 | diffSquared.reduce((partialSum: number, value: number) => partialSum + value, 0) / diffSquared.length 24 | ); 25 | 26 | return round(stdDev); 27 | } 28 | 29 | export function getJSON(url: string) { 30 | return fetch(url).then(async (res) => { 31 | if (res.status === 401) { 32 | throw new Error('expired'); 33 | } 34 | 35 | if (!res.ok) { 36 | throw new Error(res.statusText); 37 | } 38 | 39 | return await res.json(); 40 | }); 41 | } 42 | 43 | export const getBestEdge = (results: TestResults[]) => 44 | maxBy(results, (result) => result.results.preflight?.stats?.mos?.average); 45 | 46 | export const round = (num: number, decimals = 2) => 47 | Math.round((num + Number.EPSILON) * 10 ** decimals) / 10 ** decimals; 48 | 49 | export function regionalizeIceUrls(edge: Edge, iceServers: RTCIceServer[]) { 50 | if (edge === 'roaming') { 51 | return iceServers; 52 | } 53 | 54 | return iceServers.map((server: RTCIceServer) => { 55 | const result = { 56 | ...server, 57 | }; 58 | 59 | if (result.url) { 60 | result.url = result.url.replace('global', edge); 61 | } 62 | 63 | if (typeof result.urls === 'string') { 64 | result.urls = result.urls.replace('global', edge); 65 | } 66 | 67 | if (Array.isArray(result.urls)) { 68 | result.urls = result.urls.map((url) => url.replace('global', edge)); 69 | } 70 | return result; 71 | }); 72 | } 73 | 74 | export const codecNameMap = { 75 | opus: 'Opus', 76 | pcmu: 'PCMU', 77 | }; 78 | 79 | export const edgeNameMap = { 80 | sydney: 'Sydney', 81 | 'sao-paulo': 'Sao Paulo', 82 | dublin: 'Dublin', 83 | frankfurt: 'Frankfurt', 84 | tokyo: 'Tokyo', 85 | singapore: 'Singapore', 86 | ashburn: 'Ashburn', 87 | roaming: 'Roaming', 88 | 'ashburn-ix': 'Ashburn IX', 89 | 'san-jose-ix': 'San Jose IX', 90 | 'london-ix': 'London IX', 91 | 'frankfurt-ix': 'Frankfurt IX', 92 | 'singapore-ix': 'Singapore IX', 93 | }; 94 | 95 | export function getEdgeName(result: TestResults) { 96 | if (result.results.preflight?.selectedEdge === 'roaming') { 97 | return `Roaming - ${edgeNameMap[result.results.preflight?.edge as Edge]}`; 98 | } 99 | 100 | return edgeNameMap[result.edge as Edge]; 101 | } 102 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react", 21 | "noImplicitAny": true, 22 | "noImplicitThis": true, 23 | "strictNullChecks": true, 24 | "noFallthroughCasesInSwitch": true 25 | }, 26 | "include": [ 27 | "src" 28 | ] 29 | } 30 | --------------------------------------------------------------------------------