├── .gitattributes ├── .github ├── CODEOWNERS └── workflows │ ├── continuous-deployment.yml │ ├── publish-release.yml │ └── pull-request-validation.yml ├── .gitignore ├── .vscode └── settings.json ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Contributing.md ├── LICENSE.md ├── README.md ├── SECURITY.md ├── STREAMING-EXTENSIONS.md ├── __tests__ ├── constants.json ├── createConversation.ts ├── directLineStreaming │ ├── TODO.md │ ├── __setup__ │ │ ├── activityTimestampComparer.ts │ │ ├── createBotProxy.ts │ │ ├── expect │ │ │ ├── activityContaining.d.ts │ │ │ └── activityContaining.ts │ │ ├── external │ │ │ └── testing-library │ │ │ │ ├── jestFakeTimersAreEnabled.ts │ │ │ │ └── waitFor.ts │ │ ├── mockObserver.ts │ │ ├── removeInline.ts │ │ ├── setupBotProxy.ts │ │ └── types │ │ │ └── Observable.ts │ ├── connect.fail.story.js │ ├── connect.success.story.js │ ├── end.story.js │ ├── options.networkInformation.story.ts │ ├── postActivity.fail.story.js │ ├── postActivity.success.story.js │ ├── retryConnect.fail.story.js │ └── retryConnect.success.story.js ├── happy.conversationUpdate.js ├── happy.dlstreamConnection.js ├── happy.localeOnStartConversation.js ├── happy.postActivity.js ├── happy.receiveAttachmentStreams.js ├── happy.replaceActivityFromId.js ├── happy.uploadAttachmentStreams.js ├── happy.uploadAttachments.js ├── happy.userIdOnStartConversation.js ├── index.html ├── setup.ts ├── setup │ ├── createDirectLine.js │ ├── createDirectLineForwarder.js │ ├── createPromiseQueue.js │ ├── createServer.test.ts │ ├── createServer.ts │ ├── createUserId.js │ ├── fetchAsBase64.js │ ├── get-port.d.ts │ ├── getEchoActivity.js │ ├── has-resolved.d.ts │ ├── jsdomEnvironmentWithProxy.js │ ├── observableToPromise.js │ ├── postActivity.js │ ├── sleep.js │ ├── waitForBotToEcho.js │ ├── waitForBotToRespond.js │ ├── waitForConnected.js │ └── waitForObservable.js ├── unhappy.brokenWebSocket.js ├── unhappy.invalidLocaleOnStartConversation.js ├── unhappy.postActivityFatalAfterConnect.js └── unhappy.setUserIdAfterConnect.js ├── babel.config.json ├── docs ├── API.md └── media │ ├── FrameWorkDirectLineJS@1x.png │ └── README.md ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── createDeferred.ts ├── dedupeFilenames.spec.js ├── dedupeFilenames.ts ├── directLine.mock.ts ├── directLine.test.ts ├── directLine.ts ├── directLineStreaming.ts ├── parseFilename.js ├── parseFilename.spec.js └── streaming │ ├── NetworkInformation.d.ts │ ├── WebSocketClientWithNetworkInformation.spec.ts │ ├── WebSocketClientWithNetworkInformation.test.ts │ └── WebSocketClientWithNetworkInformation.ts ├── tsconfig.json ├── webpack-development.config.js ├── webpack-watch.config.js └── webpack.config.js /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | 3 | *.tsx text eol=lf 4 | *.ts text eol=lf 5 | *.css text eol=lf 6 | *.js text eol=lf 7 | *.html text eol=lf 8 | *.map text eol=lf 9 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Lines starting with '#' are comments. 2 | # Each line is a file pattern followed by one or more owners. 3 | 4 | # More details are here: https://help.github.com/articles/about-codeowners/ 5 | 6 | # The '*' pattern is global owners. 7 | 8 | # Order is important. The last matching pattern has the most precedence. 9 | # The folders are ordered as follows: 10 | 11 | # In each subsection folders are ordered first by depth, then alphabetically. 12 | # This should make it easy to add new rules without breaking existing ones. 13 | 14 | # Global rule: 15 | * @microsoft/botframework-sdk -------------------------------------------------------------------------------- /.github/workflows/continuous-deployment.yml: -------------------------------------------------------------------------------- 1 | name: Continuous deployment 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - master 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [18.x] 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Use Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | cache: 'npm' 24 | - id: set-version 25 | name: Run npx version-from-git --no-git-tag-version 26 | run: | 27 | npx version-from-git --no-git-tag-version 28 | echo version=`cat package.json | jq -r '.version'` > $GITHUB_OUTPUT 29 | - run: npm clean-install 30 | - run: npm run prepublishOnly 31 | - name: Upload tarball artifact 32 | uses: actions/upload-artifact@v4.6.2 33 | with: 34 | name: bundle 35 | path: ./dist 36 | - run: npm pack 37 | - name: Upload tarball artifact 38 | uses: actions/upload-artifact@v4.6.2 39 | with: 40 | name: tarball 41 | path: ./*.tgz 42 | 43 | publish: 44 | needs: build 45 | runs-on: ubuntu-latest 46 | environment: prerelease 47 | 48 | steps: 49 | - uses: actions/setup-node@v3 50 | with: 51 | node-version: 18 52 | registry-url: https://registry.npmjs.org/ 53 | - name: Download tarball artifact 54 | uses: actions/download-artifact@v4.2.1 55 | with: 56 | name: tarball 57 | - id: get-version 58 | name: Get version 59 | run: | 60 | echo package-name=`tar --extract --file=\`ls ./*.tgz\` --to-stdout package/package.json | jq -r .name` >> $GITHUB_OUTPUT 61 | echo version=`tar --extract --file=\`ls ./*.tgz\` --to-stdout package/package.json | jq -r .version` >> $GITHUB_OUTPUT 62 | - if: ${{ !contains(steps.get-version.outputs.version, '-') }} 63 | name: Validate version 64 | run: | 65 | echo Cannot publish production version 66 | exit 1 67 | - run: npm publish --access public --tag ${{ github.ref_name }} `ls *.tgz` 68 | env: 69 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 70 | - name: Generate job summary 71 | run: echo "NPM package has been published to https://npmjs.com/package/${{ steps.get-version.outputs.package-name }}/v/${{ steps.get-version.outputs.version }}." > $GITHUB_STEP_SUMMARY 72 | -------------------------------------------------------------------------------- /.github/workflows/publish-release.yml: -------------------------------------------------------------------------------- 1 | name: Publish release on push tag 2 | 3 | on: 4 | push: 5 | tags: 'v*' 6 | 7 | jobs: 8 | build-and-draft: 9 | permissions: 10 | contents: write 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: Use Node.js 18 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: 18 19 | cache: 'npm' 20 | - id: get-version 21 | name: Get version 22 | run: echo version=`cat package.json | jq -r '.version'` >> $GITHUB_OUTPUT 23 | - name: Validate version 24 | if: ${{ contains(steps.get-version.outputs.version, '-') }} 25 | run: | 26 | echo Version number must not be a prerelease. 27 | exit 1 28 | - run: npm clean-install 29 | - run: npm run prepublishOnly 30 | - name: Upload tarball artifact 31 | uses: actions/upload-artifact@v4.6.2 32 | with: 33 | name: bundle 34 | path: ./dist 35 | - run: npm pack 36 | - name: Upload tarball artifact 37 | uses: actions/upload-artifact@v4.6.2 38 | with: 39 | name: tarball 40 | path: ./*.tgz 41 | - name: Draft a new release 42 | run: gh release create ${{ github.ref_name }} ./dist/directline.js ./*.tgz --draft --notes-file ./CHANGELOG.md 43 | env: 44 | GH_TOKEN: ${{ github.token }} 45 | 46 | publish-package: 47 | environment: production 48 | needs: build-and-draft 49 | runs-on: ubuntu-latest 50 | 51 | steps: 52 | - uses: actions/setup-node@v3 53 | with: 54 | node-version: 18 55 | registry-url: https://registry.npmjs.org/ 56 | - name: Download tarball artifact 57 | uses: actions/download-artifact@v4.2.1 58 | with: 59 | name: tarball 60 | - id: get-version 61 | name: Get version 62 | run: | 63 | echo package-name=`tar --extract --file=\`ls ./*.tgz\` --to-stdout package/package.json | jq -r .name` >> $GITHUB_OUTPUT 64 | echo version=`tar --extract --file=\`ls ./*.tgz\` --to-stdout package/package.json | jq -r .version` >> $GITHUB_OUTPUT 65 | - run: npm publish --access public `ls ./*.tgz` 66 | env: 67 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 68 | - name: Generate job summary 69 | run: echo "NPM package has been published to https://npmjs.com/package/${{ steps.get-version.outputs.package-name }}/v/${{ steps.get-version.outputs.version }}." > $GITHUB_STEP_SUMMARY 70 | 71 | publish-release: 72 | needs: 73 | - build-and-draft 74 | - publish-package 75 | permissions: 76 | contents: write 77 | runs-on: ubuntu-latest 78 | 79 | steps: 80 | - name: Publish release 81 | run: gh release edit ${{ github.ref_name }} --draft=false --repo ${{ github.repository }} 82 | env: 83 | GH_TOKEN: ${{ github.token }} 84 | - name: Generate job summary 85 | run: echo "GitHub release created at https://github.com/${{ github.repository }}/releases/tag/${{ github.ref_name }}." >> $GITHUB_STEP_SUMMARY 86 | -------------------------------------------------------------------------------- /.github/workflows/pull-request-validation.yml: -------------------------------------------------------------------------------- 1 | name: Pull request validation 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | - master 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [18.x, 20.x, 22.x] 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | cache: 'npm' 25 | 26 | - run: npm clean-install 27 | 28 | - run: npm run prepublishOnly 29 | 30 | - run: npm pack 31 | 32 | - name: Upload tarball artifact 33 | uses: actions/upload-artifact@v4.6.2 34 | with: 35 | name: bundle-${{ matrix.node-version }} 36 | path: ./dist 37 | 38 | - name: Upload tarball artifact 39 | uses: actions/upload-artifact@v4.6.2 40 | with: 41 | name: tarball-${{ matrix.node-version }} 42 | path: ./*.tgz 43 | 44 | - run: npm test 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.env 2 | /.vscode 3 | /*.tgz 4 | /dist 5 | /lib 6 | /node_modules 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.tabSize": 2, 3 | "editor.trimAutoWhitespace": true, 4 | "files.trimFinalNewlines": true, 5 | "files.trimTrailingWhitespace": true, 6 | "search.exclude": { 7 | "lib": true 8 | }, 9 | "debug.node.autoAttach": "on" 10 | } 11 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Microsoft Open Source Code of Conduct 2 | 3 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 4 | 5 | Resources: 6 | 7 | - [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) 8 | - [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) 9 | - Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns -------------------------------------------------------------------------------- /Contributing.md: -------------------------------------------------------------------------------- 1 | # Instructions for Logging Issues 2 | 3 | ## 1. Search for Duplicates 4 | 5 | [Search the existing issues](https://github.com/Microsoft/BotBuilder-Samples/issues) before logging a new one. 6 | 7 | ## 2. Do you have a question? 8 | 9 | The issue tracker is for **issues**, in other words, bugs and suggestions. 10 | If you have a *question*, *feedback* or *suggestions*, please check our [support page](http://docs.botframework.com/support/). 11 | 12 | In general, things we find useful when reviewing suggestions are: 13 | * A description of the problem you're trying to solve 14 | * An overview of the suggested solution 15 | * Examples of how the suggestion would work in various places 16 | * Code examples showing e.g. "this would be an error, this wouldn't" 17 | * Code examples showing the generated JavaScript (if applicable) 18 | * If relevant, precedent in other languages can be useful for establishing context and expected behavior 19 | 20 | ## 3. Did you find a bug? 21 | 22 | When logging a bug, please be sure to include the following: 23 | * Which sample and in what programming language 24 | * If at all possible, an *isolated* way to reproduce the behavior 25 | * The behavior you expect to see, and the actual behavior 26 | 27 | # Instructions for Contributing Code 28 | 29 | ## Code of Conduct 30 | 31 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. 32 | 33 | ## Contributing bug fixes and features 34 | 35 | BotBuilder Samples is currently accepting contributions in the form of bug fixes and new features. Any submission must have an issue tracking it in the issue tracker that has been approved by the BotBuilder team. Your pull request should include a link to the bug that you are fixing. If you've submitted a PR for a bug, please post a comment in the bug to avoid duplication of effort. 36 | 37 | ## Legal 38 | 39 | If your contribution is more than 15 lines of code, you will need to complete a Contributor License Agreement (CLA). Briefly, this agreement testifies that you are granting us permission to use the submitted change according to the terms of the project's license, and that the work being submitted is under appropriate copyright. 40 | 41 | Please submit a Contributor License Agreement (CLA) before submitting a pull request. You may visit https://cla.azure.com to sign digitally. Alternatively, download the agreement ([Microsoft Contribution License Agreement.docx](https://www.codeplex.com/Download?ProjectName=typescript&DownloadId=822190) or [Microsoft Contribution License Agreement.pdf](https://www.codeplex.com/Download?ProjectName=typescript&DownloadId=921298)), sign, scan, and email it back to . Be sure to include your github user name along with the agreement. Once we have received the signed CLA, we'll review the request. 42 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 - present Microsoft Corporation 4 | 5 | All rights reserved. 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Bot Framework DirectLineJS](./docs/media/FrameWorkDirectLineJS@1x.png) 2 | 3 | # Microsoft Bot Framework Direct Line JS Client 4 | 5 | [![Build Status](https://travis-ci.org/Microsoft/BotFramework-DirectLineJS.svg?branch=master)](https://travis-ci.org/Microsoft/BotFramework-DirectLineJS) 6 | 7 | Client library for the [Microsoft Bot Framework](http://www.botframework.com) _[Direct Line](https://docs.botframework.com/en-us/restapi/directline3/)_ protocol. 8 | 9 | Used by [WebChat](https://github.com/Microsoft/BotFramework-WebChat) and thus (by extension) [Emulator](https://github.com/Microsoft/BotFramework-Emulator), WebChat channel, and [Azure Bot Service](https://azure.microsoft.com/en-us/services/bot-service/). 10 | 11 | ## FAQ 12 | 13 | ### _Who is this for?_ 14 | 15 | Anyone who is building a Bot Framework JavaScript client who does not want to use [WebChat](https://github.com/Microsoft/BotFramework-WebChat). 16 | 17 | If you're currently using WebChat, you don't need to make any changes as it includes this package. 18 | 19 | ### _What is that funny `subscribe()` method in the samples below?_ 20 | 21 | Instead of callbacks or Promises, this library handles async operations using Observables. Try it, you'll like it! For more information, check out [RxJS](https://github.com/reactivex/rxjs/). 22 | 23 | ### _Can I use [TypeScript](http://www.typescriptlang.com)?_ 24 | 25 | You bet. 26 | 27 | ### How ready for prime time is this library? 28 | 29 | This is an official Microsoft-supported library, and is considered largely complete. Future changes (aside from supporting future updates to the Direct Line protocol) will likely be limited to bug fixes, performance improvements, tutorials, and samples. The big missing piece here is unit tests. 30 | 31 | That said, the public API is still subject to change. 32 | 33 | ### Why the library did not detect Web Socket disconnections? 34 | 35 | On iOS/iPadOS, when network change from Wi-Fi to cellular, the `WebSocket` object will be stalled without any errors. This is not detectable nor workaroundable without any additional assistance. The issue is related to an experimental feature named "NSURLSession WebSocket". The feature is enabled by default on iOS/iPadOS 15 and up. 36 | 37 | An option named `networkInformation` can be used to assist the library to detect any connection issues. The option is based on [W3C Network Information API](https://developer.mozilla.org/en-US/docs/Web/API/Network_Information_API) and it should implement at least 2 members: 38 | 39 | - [A `type` property](https://developer.mozilla.org/en-US/docs/Web/API/NetworkInformation/type) to indicate the current network type 40 | - When the `type` is `"offline"`, network is not available and no connection will be made 41 | - [A `change` event](https://developer.mozilla.org/en-US/docs/Web/API/NetworkInformation/change_event) should dispatch when the `type` property change 42 | 43 | However, Safari on iOS/iPadOS [does not support W3C Network Information API](https://bugs.webkit.org/show_bug.cgi?id=185697). It is up to web developers to implement the `NetworkInformation` polyfill. 44 | 45 | One effective way to detect network type change is to subscribe to a [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) source. The service would send a message every 30 seconds. If network type changed and current network type is no longer available, the connection will be closed prematurely and an `error` event will be dispatched to the [`EventSource`](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) instance. Upon receiving the `error` event, the `NetworkInformation.type` should then change to `"offline"`. The browser would automatically retry the Server-Sent Events connection. Upon receiving an `open` event, the polyfill should change the `type` back to `"unknown"`. 46 | 47 | If the library is being used in a native iOS/iPadOS app, a less resource-intensive solution would be partially implementing the [Network Information API](https://developer.mozilla.org/en-US/docs/Web/API/Network_Information_API) using [`NWPathMonitor`](https://developer.apple.com/documentation/network/nwpathmonitor). When network change happens, the `NetworkInformation` instance should update the [`type` property](https://developer.mozilla.org/en-US/docs/Web/API/NetworkInformation/type) based on network type and dispatch a [`change` event](https://developer.mozilla.org/en-US/docs/Web/API/NetworkInformation/change_event). 48 | 49 | ## How to build from source 50 | 51 | 0. Clone this repo 52 | 1. `npm install` 53 | 2. `npm run build` (or `npm run watch` to rebuild on every change, or `npm run prepublishOnly` to build production) 54 | 55 | ## How to include in your app 56 | 57 | There are several ways: 58 | 59 | 1. Build from scratch and include either `/directLine.js` (webpacked with rxjs) or `lib/directline.js` in your app 60 | 2. `npm install botframework-directlinejs` 61 | 62 | ## Using from within a Node environment 63 | 64 | This library uses RxJs/AjaxObserverable which is meant for use in a DOM environment. That doesn't mean you can't also use it from Node though, you just need to do a couple of extra things: 65 | 66 | 1. `npm install --save ws xhr2` 67 | 2. Add the following towards the top of your main application file: 68 | 69 | ```typescript 70 | global.XMLHttpRequest = require('xhr2'); 71 | global.WebSocket = require('ws'); 72 | ``` 73 | 74 | ## How to create and use a directLine object 75 | 76 | ### Obtain security credentials for your bot: 77 | 78 | 1. If you haven't already, [register your bot](https://azure.microsoft.com/en-us/services/bot-service/). 79 | 2. Add a DirectLine (**not WebChat**) channel, and generate a Direct Line Secret. Make sure Direct Line 3.0 is enabled. 80 | 3. For testing you can use your Direct Line Secret as a security token, but for production you will likely want to exchange that Secret for a Token as detailed in the Direct Line [documentation](https://docs.microsoft.com/en-us/azure/bot-service/bot-service-channel-directline?view=azure-bot-service-4.0). 81 | 82 | ### Create a DirectLine object: 83 | 84 | ```typescript 85 | import { DirectLine } from 'botframework-directlinejs'; 86 | // For Node.js: 87 | // const { DirectLine } = require('botframework-directlinejs'); 88 | 89 | var directLine = new DirectLine({ 90 | secret: /* put your Direct Line secret here */, 91 | token: /* or put your Direct Line token here (supply secret OR token, not both) */, 92 | domain: /* optional: if you are not using the default Direct Line endpoint, e.g. if you are using a region-specific endpoint, put its full URL here */ 93 | webSocket: /* optional: false if you want to use polling GET to receive messages. Defaults to true (use WebSocket). */, 94 | pollingInterval: /* optional: set polling interval in milliseconds. Defaults to 1000 */, 95 | timeout: /* optional: a timeout in milliseconds for requests to the bot. Defaults to 20000 */, 96 | conversationStartProperties: { /* optional: properties to send to the bot on conversation start */ 97 | locale: 'en-US' 98 | } 99 | }); 100 | ``` 101 | 102 | ### Post activities to the bot: 103 | 104 | ```typescript 105 | directLine 106 | .postActivity({ 107 | from: { id: 'myUserId', name: 'myUserName' }, // required (from.name is optional) 108 | type: 'message', 109 | text: 'a message for you, Rudy' 110 | }) 111 | .subscribe( 112 | id => console.log('Posted activity, assigned ID ', id), 113 | error => console.log('Error posting activity', error) 114 | ); 115 | ``` 116 | 117 | You can also post messages with attachments, and non-message activities such as events, by supplying the appropriate fields in the activity. 118 | 119 | ### Listen to activities sent from the bot: 120 | 121 | ```typescript 122 | directLine.activity$.subscribe(activity => console.log('received activity ', activity)); 123 | ``` 124 | 125 | You can use RxJS operators on incoming activities. To see only message activities: 126 | 127 | ```typescript 128 | directLine.activity$ 129 | .filter(activity => activity.type === 'message') 130 | .subscribe(message => console.log('received message ', message)); 131 | ``` 132 | 133 | Direct Line will helpfully send your client a copy of every sent activity, so a common pattern is to filter incoming messages on `from`: 134 | 135 | ```typescript 136 | directLine.activity$ 137 | .filter(activity => activity.type === 'message' && activity.from.id === 'yourBotHandle') 138 | .subscribe(message => console.log('received message ', message)); 139 | ``` 140 | 141 | ### Monitor connection status 142 | 143 | Subscribing to either `postActivity` or `activity$` will start the process of connecting to the bot. Your app can listen to the connection status and react appropriately : 144 | 145 | ```typescript 146 | import { ConnectionStatus } from 'botframework-directlinejs'; 147 | 148 | directLine.connectionStatus$.subscribe(connectionStatus => { 149 | switch (connectionStatus) { 150 | case ConnectionStatus.Uninitialized: // the status when the DirectLine object is first created/constructed 151 | case ConnectionStatus.Connecting: // currently trying to connect to the conversation 152 | case ConnectionStatus.Online: // successfully connected to the converstaion. Connection is healthy so far as we know. 153 | case ConnectionStatus.ExpiredToken: // last operation errored out with an expired token. Your app should supply a new one. 154 | case ConnectionStatus.FailedToConnect: // the initial attempt to connect to the conversation failed. No recovery possible. 155 | case ConnectionStatus.Ended: // the bot ended the conversation 156 | } 157 | }); 158 | ``` 159 | 160 | ### Reconnect to a conversation 161 | 162 | If your app created your DirectLine object by passing a token, DirectLine will refresh that token every 15 minutes. 163 | Should your client lose connectivity (e.g. close laptop, fail to pay Internet access bill, go under a tunnel), `connectionStatus$` 164 | will change to `ConnectionStatus.ExpiredToken`. Your app can request a new token from its server, which should call 165 | the [Reconnect](https://docs.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-direct-line-3-0-reconnect-to-conversation?view=azure-bot-service-4.0) API. 166 | The resultant Conversation object can then be passed by the app to DirectLine. 167 | 168 | ```typescript 169 | var conversation = /* a Conversation object obtained from your app's server */; 170 | directLine.reconnect(conversation); 171 | ``` 172 | 173 | ### Resume an existing conversation 174 | 175 | When using DirectLine with WebChat, closing the current tab or refreshing the page will create a new conversation in most cases. You can resume an existing conversation to keep the user in the same context. 176 | 177 | **When using a secret** you can resume a conversation by: 178 | 179 | - Storing the conversationid (in a _permanent_ place, like local storage) 180 | - Giving this value back while creating the DirectLine object along with the secret 181 | 182 | ```typescript 183 | import { DirectLine } from 'botframework-directlinejs'; 184 | 185 | const dl = new DirectLine({ 186 | secret: /* SECRET */, 187 | conversationId: /* the conversationid you stored from previous conversation */ 188 | }); 189 | ``` 190 | 191 | **When using a token** you can resume a conversation by: 192 | 193 | - Storing the conversationid and your token (in a _permanent_ place, like local storage) 194 | - Calling the DirectLine reconnect API yourself to get a refreshed token and a streamurl 195 | - Creating the DirectLine object using the ConversationId, Token, and StreamUrl 196 | 197 | ```typescript 198 | import { DirectLine } from 'botframework-directlinejs'; 199 | 200 | const dl = new DirectLine({ 201 | token: /* the token you retrieved while reconnecting */, 202 | streamUrl: /* the streamUrl you retrieved while reconnecting */, 203 | conversationId: /* the conversationid you stored from previous conversation */ 204 | }); 205 | ``` 206 | 207 | **Getting any history that Direct Line has cached** : you can retrieve history using watermarks: 208 | You can see the watermark as an _activity 'bookmark'_. The resuming scenario will replay all the conversation activities from the watermark you specify. 209 | 210 | ```typescript 211 | import { DirectLine } from 'botframework-directlinejs'; 212 | 213 | const dl = new DirectLine({ 214 | token: /* the token you retrieved while reconnecting */, 215 | streamUrl: /* the streamUrl you retrieved while reconnecting */, 216 | conversationId: /* the conversationid you stored from previous conversation */, 217 | watermark: /* a watermark you saved from a previous conversation */, 218 | webSocket: false 219 | }); 220 | ``` 221 | 222 | ## Contributing 223 | 224 | This project welcomes contributions and suggestions. Most contributions require you to agree to a 225 | Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us 226 | the rights to use your contribution. For details, visit https://cla.microsoft.com. 227 | 228 | When you submit a pull request, a CLA-bot will automatically determine whether you need to provide 229 | a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the instructions 230 | provided by the bot. You will only need to do this once across all repos using our CLA. 231 | 232 | When you submit a pull request, a CLA-bot will automatically determine whether you need to provide 233 | a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the instructions 234 | provided by the bot. You will only need to do this once across all repos using our CLA. 235 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 236 | For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or 237 | contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. 238 | 239 | ## Reporting Security Issues 240 | 241 | Security issues and bugs should be reported privately, via email, to the Microsoft Security Response Center (MSRC) at [secure@microsoft.com](mailto:secure@microsoft.com). You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Further information, including the [MSRC PGP](https://technet.microsoft.com/en-us/security/dn606155) key, can be found in the [Security TechCenter](https://technet.microsoft.com/en-us/security/default). 242 | 243 | Copyright (c) Microsoft Corporation. All rights reserved. 244 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://aka.ms/opensource/security/definition), please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://aka.ms/opensource/security/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://aka.ms/opensource/security/pgpkey). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://aka.ms/opensource/security/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://aka.ms/opensource/security/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/opensource/security/cvd). 40 | 41 | 42 | -------------------------------------------------------------------------------- /STREAMING-EXTENSIONS.md: -------------------------------------------------------------------------------- 1 | # Direct Line Streaming Extensions 2 | 3 | This is CONTRIBUTING.md for Direct Line Streaming Extensions. 4 | 5 | ## Run automated tests 6 | 7 | - Clone this repository branch 8 | - `npm ci` 9 | - Please ignore `node-gyp` errors, it is a warning instead 10 | - `npm test` 11 | 12 | > You don't need to run `npm run build`. Jest will rebuild the source code on-the-fly for each test run. 13 | 14 | If you want to run tests in watch mode, run `npm test -- --watch`. 15 | 16 | ## Build development bundle 17 | 18 | - Clone this repository 19 | - `npm ci` 20 | - `npm run build` 21 | 22 | After build succeeded, you can use the JavaScript bundle at `/dist/directline.js`. This is development build. It is not minified and contains instrumentation code for code coverage. 23 | 24 | To use the bundle: 25 | 26 | ```js 27 | const { DirectLine } = window.DirectLine; 28 | 29 | const directLine = new DirectLineStreaming({ 30 | conversationId: '', 31 | domain: 'https://.../.bot/v3/directline', 32 | token: '', 33 | webSocket: true 34 | }); 35 | 36 | // Start the connection and console-logging every incoming activity 37 | directLine.activity$.subscribe({ 38 | next(activity) { console.log(activity); } 39 | }); 40 | ``` 41 | 42 | ## CI/CD pipeline 43 | 44 | ### Build status 45 | 46 | For latest build status, navigate to https://travis-ci.org/microsoft/BotFramework-DirectLineJS/branches, and select `ckk/protocoljs` branch. 47 | 48 | ### Test in Web Chat 49 | 50 | The last successful build can be tested with Web Chat and MockBot. 51 | 52 | - Navigate to https://compulim.github.io/webchat-loader/ 53 | - Click `Dev` or select `` from the dropdown list 54 | - Click `[Public] MockBot with Streaming Extensions` 55 | - Click `Open Web Chat in a new window` 56 | 57 | Type `help` to MockBot for list of commands. 58 | 59 | ### Build artifacts 60 | 61 | After successful build, artifacts are published to https://github.com/microsoft/BotFramework-DirectLineJS/releases/tag/dev-streamingextensions. 62 | 63 | For easier consumption, in the assets, [`directline.js`](https://github.com/microsoft/BotFramework-DirectLineJS/releases/download/dev-streamingextensions/directline.js) is the bundle from last successful build. You can use the HTML code below to use latest DirectLineJS with Web Chat 4.5.2: 64 | 65 | ```html 66 | 67 | 68 | 69 | Web Chat with Streaming Extensions 70 | 71 | 72 | 76 | 77 | 78 | 93 | 94 | 95 | ``` 96 | 97 | ### Source code 98 | 99 | Run `git checkout dev-streamingextensions` to checkout the source code of the last successful build. 100 | -------------------------------------------------------------------------------- /__tests__/constants.json: -------------------------------------------------------------------------------- 1 | { 2 | "userId": "dl_12345", 3 | "timeouts": { 4 | "default": 5000, 5 | "rest": 10000, 6 | "streamingExtensions": 10000, 7 | "webSocket": 10000 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /__tests__/createConversation.ts: -------------------------------------------------------------------------------- 1 | /** @jest-environment ./__tests__/setup/jsdomEnvironmentWithProxy */ 2 | 3 | /// 4 | 5 | import createServer from './setup/createServer'; 6 | import { ConnectionStatus, DirectLine } from '../src/directLine'; 7 | 8 | const conversationId = Math.random(); 9 | 10 | test('Create conversation should set conversation ID', async () => { 11 | const { dispose, port, promises } = await createServer({ 12 | playbacks: [{ 13 | req: { method: 'POST', url: '/v3/directline/conversations' }, 14 | res: { body: { 15 | conversationId: conversationId 16 | } } 17 | }] 18 | }); 19 | 20 | try { 21 | const directLine = new DirectLine({ 22 | domain: `http://localhost:${ port }/v3/directline`, 23 | webSocket: false 24 | }); 25 | 26 | const subscription = directLine.activity$.subscribe(() => {}); 27 | 28 | await Promise.all([ 29 | promises[0], 30 | new Promise(resolve => { 31 | directLine.connectionStatus$.subscribe(value => value === ConnectionStatus.Online && resolve()) 32 | }) 33 | ]); 34 | 35 | expect(directLine).toHaveProperty('conversationId', conversationId); 36 | 37 | subscription.unsubscribe(); 38 | } finally { 39 | await dispose(); 40 | } 41 | }); 42 | -------------------------------------------------------------------------------- /__tests__/directLineStreaming/TODO.md: -------------------------------------------------------------------------------- 1 | # To-dos 2 | 3 | Due to resources constraints, while we are working on PR #404 to improve the code quality, there are scenarios we missed. 4 | 5 | - [ ] TEST: Connect with an invalid token 6 | - WHEN: Create chat adapter with an invalid token 7 | - THEN: Should observe `Connecting` -> `FailedToConnect` 8 | - WHEN: Call `reconnect()` with a valid token 9 | - THEN: Should observe `Online` 10 | - WHEN: Call `postActivity()` 11 | - THEN: Should send message 12 | - THEN: Should receive bot reply 13 | - [ ] TEST: Connect with a non-existing conversation ID 14 | - WHEN: Create chat adapter with a non-existing conversation ID 15 | - THEN: Should observe `Connecting` -> `FailedToConnect` 16 | - WHEN: Call `reconnect()` with a valid conversation ID 17 | - THEN: Should observe `Online` 18 | - WHEN: Call `postActivity()` 19 | - THEN: Should send message 20 | - THEN: Should receive bot reply 21 | - [ ] TEST: Renew token should work 22 | - [ ] TEST: Call `end()` after `FailedToConnect` 23 | - WHEN: Call `connect()` without a server running 24 | - THEN: Should connect 3 times 25 | - THEN: Should observe `FailedToConnect` 26 | - WHEN: Call `end()` 27 | - THEN: `activity$` should observe completion 28 | - THEN: `connectionStatus$` Should observe `Ended` -> completion 29 | - WHEN: Call `reconnect()` 30 | - THEN: Should throw 31 | - [ ] TEST: `FailedToConnect` should be observed immediately after 3 unstable connections 32 | - WHEN: Call connect() 33 | - THEN: Should observe `Online` 34 | - WHEN: Kill the socket immediately 35 | - THEN: Should observe `Connecting` 36 | - The observation should be immediate (< 3 seconds) 37 | - THEN: Should reconnect after 3-15 seconds 38 | - WHEN: Allow it to retry-connect successfully 39 | - THEN: Should observe `Online` 40 | - WHEN: Kill the socket immediately again 41 | - THEN: Should observe `Connecting` 42 | - The observation should be immediate (< 3 seconds) 43 | - THEN: Should reconnect after 3-15 seconds 44 | - WHEN: Kill the socket immediately one more time 45 | - THEN: Should observe `FailedToConnect` 46 | - The observation should be immediate (< 3 seconds) 47 | - WHEN: Call reconnect() 48 | - THEN: Should reconnect immediately 49 | - THEN: Should observe `Online` 50 | - [ ] Make sure all state transitions are tested (see `API.md`) 51 | - In the state diagram in `API.md`, make sure all edges (arrows) has their own tests 52 | - Certain scenarios are time-sensitive, the time to the call must be asserted 53 | - For example, when transitioning from `Online` to `Connecting` for the first time, the Web Socket connection must be established within first 3 seconds 54 | - If the connection is being established after 3 seconds, it means a backoff is done 55 | - Backoff is undesirable for the first retry attempt 56 | - Class functions works differently when in different state, make sure they are properly tested 57 | - For example, when the state is `Ended`, call to `reconnect()` will throw immediately 58 | - When the state is `Connecting`, call to `postActivity()` should fail 59 | -------------------------------------------------------------------------------- /__tests__/directLineStreaming/__setup__/activityTimestampComparer.ts: -------------------------------------------------------------------------------- 1 | export default function activityTimestampComparer({ timestamp: x }, { timestamp: y }) { 2 | return new Date(x).getTime() - new Date(y).getTime(); 3 | } 4 | -------------------------------------------------------------------------------- /__tests__/directLineStreaming/__setup__/createBotProxy.ts: -------------------------------------------------------------------------------- 1 | import { createProxyMiddleware, responseInterceptor } from 'http-proxy-middleware'; 2 | import { createServer } from 'http'; 3 | import { match } from 'path-to-regexp'; 4 | import WebSocket, { WebSocketServer } from 'ws'; 5 | import express from 'express'; 6 | 7 | import removeInline from './removeInline'; 8 | 9 | import type { Data } from 'ws'; 10 | import type { IncomingMessage } from 'http'; 11 | import type { Options } from 'http-proxy-middleware'; 12 | import type { Socket } from 'net'; 13 | 14 | type OnUpgradeHandler = (req: IncomingMessage, socket: Socket, head: Buffer, next: OnUpgradeHandler) => void; 15 | 16 | type OnWebSocketMessageHandler = ( 17 | data: Data, 18 | socket: WebSocket, 19 | req: IncomingMessage, 20 | next: OnWebSocketMessageHandler 21 | ) => void; 22 | 23 | type CreateBotProxyInit = { 24 | onUpgrade?: OnUpgradeHandler; 25 | onWebSocketReceiveMessage?: OnWebSocketMessageHandler; 26 | onWebSocketSendMessage?: OnWebSocketMessageHandler; 27 | streamingBotURL?: string; 28 | }; 29 | 30 | type CreateBotProxyReturnValue = { 31 | cleanUp: () => void; 32 | closeAllWebSocketConnections: () => void; 33 | directLineStreamingURL: string; 34 | directLineURL: string; 35 | }; 36 | 37 | const matchDirectLineStreamingProtocol = match('/.bot/', { decode: decodeURIComponent, end: false }); 38 | 39 | export default function createBotProxy(init?: CreateBotProxyInit): Promise { 40 | const onUpgrade = init?.onUpgrade || ((req, socket, head, next) => next(req, socket, head, () => {})); 41 | const onWebSocketReceiveMessage = 42 | init?.onWebSocketReceiveMessage || ((data, socket, req, next) => next(data, socket, req, () => {})); 43 | const onWebSocketSendMessage = 44 | init?.onWebSocketSendMessage || ((data, socket, req, next) => next(data, socket, req, () => {})); 45 | const streamingBotURL = init?.streamingBotURL; 46 | 47 | return new Promise((resolve, reject) => { 48 | try { 49 | const activeSockets: Socket[] = []; 50 | const app = express(); 51 | 52 | streamingBotURL && 53 | app.use('/.bot/', createProxyMiddleware({ changeOrigin: true, logLevel: 'silent', target: streamingBotURL })); 54 | 55 | const onProxyRes: Options['onProxyRes'] = responseInterceptor( 56 | async (responseBuffer, proxyRes: IncomingMessage) => { 57 | const { 58 | socket: { localAddress, localPort }, 59 | statusCode 60 | } = proxyRes; 61 | 62 | if (statusCode && statusCode >= 200 && statusCode < 300) { 63 | try { 64 | const json = JSON.parse(responseBuffer.toString('utf8')); 65 | 66 | if (json.streamUrl) { 67 | return JSON.stringify({ 68 | ...json, 69 | streamUrl: json.streamUrl.replace( 70 | /^wss:\/\/directline.botframework.com\/v3\/directline\//, 71 | `ws://${localAddress}:${localPort}/v3/directline/` 72 | ) 73 | }); 74 | } 75 | } catch (error) { 76 | // Returns original response if it is not a JSON. 77 | } 78 | } 79 | 80 | return responseBuffer; 81 | 82 | // There is a typing bug in `http-proxy-middleware`. 83 | // The return type of `responseIntercept` does not match `onProxyRes`. 84 | } 85 | ); 86 | 87 | app.use( 88 | '/v3/directline', 89 | createProxyMiddleware({ 90 | changeOrigin: true, 91 | logLevel: 'silent', 92 | onProxyRes, 93 | selfHandleResponse: true, 94 | target: 'https://directline.botframework.com/' 95 | }) 96 | ); 97 | 98 | const webSocketProxy = new WebSocketServer({ noServer: true }); 99 | 100 | webSocketProxy.on('connection', (socket: WebSocket, proxySocket: WebSocket, req: IncomingMessage) => { 101 | socket.addEventListener('message', ({ data }) => 102 | onWebSocketSendMessage(data, proxySocket, req, (data, proxySocket) => proxySocket.send(data)) 103 | ); 104 | 105 | proxySocket.addEventListener('message', ({ data }) => 106 | onWebSocketReceiveMessage(data, socket, req, (data, socket) => socket.send(data)) 107 | ); 108 | }); 109 | 110 | const server = createServer(app); 111 | 112 | server.on('error', reject); 113 | 114 | server.on('upgrade', (req: IncomingMessage, socket: Socket, head: Buffer) => 115 | onUpgrade(req, socket, head, (req, socket, head) => { 116 | activeSockets.push(socket); 117 | 118 | socket.once('close', () => removeInline(activeSockets, socket)); 119 | 120 | const requestURL = req.url || ''; 121 | 122 | const isDirectLineStreaming = !!matchDirectLineStreamingProtocol(requestURL); 123 | 124 | if (isDirectLineStreaming && !streamingBotURL) { 125 | console.warn('Cannot proxy /.bot/ requests without specifying "streamingBotURL".'); 126 | 127 | return socket.end(); 128 | } 129 | 130 | const targetURL = new URL( 131 | requestURL, 132 | isDirectLineStreaming ? streamingBotURL : 'wss://directline.botframework.com/' 133 | ); 134 | 135 | // "streamingBotURL" could be "https:" instead of "wss:". 136 | targetURL.protocol = 'wss:'; 137 | 138 | const proxySocket = new WebSocket(targetURL); 139 | 140 | proxySocket.addEventListener('close', () => socket.end()); 141 | proxySocket.addEventListener('open', () => 142 | webSocketProxy.handleUpgrade(req, socket, head, ws => 143 | webSocketProxy.emit('connection', ws, proxySocket, req) 144 | ) 145 | ); 146 | proxySocket.addEventListener('error', () => {}); 147 | 148 | socket.once('close', () => proxySocket.close()); 149 | }) 150 | ); 151 | 152 | server.listen(0, '127.0.0.1', () => { 153 | const address = server.address(); 154 | 155 | if (!address) { 156 | server.close(); 157 | 158 | return reject(new Error('Cannot get address of proxy server.')); 159 | } 160 | 161 | const url = new URL(`http://${typeof address === 'string' ? address : `${address.address}:${address.port}`}`); 162 | 163 | const closeAllWebSocketConnections = () => { 164 | activeSockets.map(socket => socket.end()); 165 | activeSockets.splice(0); 166 | }; 167 | 168 | resolve({ 169 | cleanUp: () => { 170 | server.close(); 171 | 172 | // `closeAllConnections` is introduced in Node.js 18.2.0. 173 | server.closeAllConnections?.(); 174 | 175 | // Calling close() and closeAllConnections() will not close all Web Socket connections. 176 | closeAllWebSocketConnections(); 177 | }, 178 | closeAllWebSocketConnections, 179 | directLineStreamingURL: new URL('/.bot/v3/directline', url).href, 180 | directLineURL: new URL('/v3/directline', url).href 181 | }); 182 | }); 183 | } catch (error) { 184 | reject(error); 185 | } 186 | }); 187 | } 188 | -------------------------------------------------------------------------------- /__tests__/directLineStreaming/__setup__/expect/activityContaining.d.ts: -------------------------------------------------------------------------------- 1 | import '@jest/types'; 2 | 3 | declare global { 4 | namespace jest { 5 | interface Expect { 6 | activityContaining(messageText: string, mergeActivity?: { id?: string; type?: string }): any; 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /__tests__/directLineStreaming/__setup__/expect/activityContaining.ts: -------------------------------------------------------------------------------- 1 | (expect as any).activityContaining = (messageText: string, mergeActivity: { id?: string; type?: string } = {}) => 2 | expect.objectContaining({ 3 | id: expect.any(String), 4 | text: messageText, 5 | timestamp: expect.any(String), 6 | type: 'message', 7 | 8 | ...mergeActivity 9 | }); 10 | -------------------------------------------------------------------------------- /__tests__/directLineStreaming/__setup__/external/testing-library/jestFakeTimersAreEnabled.ts: -------------------------------------------------------------------------------- 1 | /*! 2 | * The MIT License (MIT) 3 | * Copyright (c) 2017 Kent C. Dodds 4 | * 5 | * Permission is hereby granted, free of charge, to any person obtaining a copy 6 | * of this software and associated documentation files (the "Software"), to deal 7 | * in the Software without restriction, including without limitation the rights 8 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | * copies of the Software, and to permit persons to whom the Software is 10 | * furnished to do so, subject to the following conditions: 11 | * 12 | * The above copyright notice and this permission notice shall be included in all 13 | * copies or substantial portions of the Software. 14 | * 15 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | * SOFTWARE. 22 | */ 23 | 24 | // Adopted from @testing-library/dom and removed dependencies on DOM. 25 | // https://github.com/testing-library/dom-testing-library/blob/eadf7485430968df8d1e1293535d78cdbeea20a5/src/helpers.js 26 | 27 | export default function jestFakeTimersAreEnabled(): boolean { 28 | /* istanbul ignore else */ 29 | // eslint-disable-next-line 30 | if (typeof jest !== 'undefined' && jest !== null) { 31 | return ( 32 | // legacy timers 33 | (setTimeout as any)._isMockFunction === true || 34 | // modern timers 35 | // eslint-disable-next-line prefer-object-has-own -- not supported by our support matrix 36 | Object.prototype.hasOwnProperty.call(setTimeout, 'clock') 37 | ); 38 | } 39 | 40 | // istanbul ignore next 41 | return false; 42 | } 43 | -------------------------------------------------------------------------------- /__tests__/directLineStreaming/__setup__/external/testing-library/waitFor.ts: -------------------------------------------------------------------------------- 1 | /*! 2 | * The MIT License (MIT) 3 | * Copyright (c) 2017 Kent C. Dodds 4 | * 5 | * Permission is hereby granted, free of charge, to any person obtaining a copy 6 | * of this software and associated documentation files (the "Software"), to deal 7 | * in the Software without restriction, including without limitation the rights 8 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | * copies of the Software, and to permit persons to whom the Software is 10 | * furnished to do so, subject to the following conditions: 11 | * 12 | * The above copyright notice and this permission notice shall be included in all 13 | * copies or substantial portions of the Software. 14 | * 15 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | * SOFTWARE. 22 | */ 23 | 24 | // Adopted from @testing-library/dom and removed dependencies on DOM. 25 | // https://github.com/testing-library/dom-testing-library/blob/eadf7485430968df8d1e1293535d78cdbeea20a5/src/wait-for.js 26 | 27 | // The code essentially do a few things: 28 | // - Set a timer to stop everything after timeout (1000 ms) 29 | // - Run fn() 30 | // - If fn() resolves, finish 31 | // - If fn() rejects, do nothing 32 | // - For every interval (50 ms), check the result of fn() 33 | // - Assert no one toggled between Jest real/fake timers 34 | // - Advance Jest fake timer 35 | // - Check result of fn(), if it recently rejected, run it again 36 | 37 | import jestFakeTimersAreEnabled from './jestFakeTimersAreEnabled'; 38 | 39 | const DEFAULT_INTERVAL = 50; 40 | const DEFAULT_TIMEOUT = 1000; 41 | 42 | const globalSetInterval = setInterval; 43 | const globalSetTimeout = setTimeout; 44 | 45 | type WaitForCallback = () => Promise | void; 46 | 47 | type WaitForInit = { 48 | interval?: number; 49 | onTimeout?: (error: Error) => Error; 50 | showOriginalStackTrace?: boolean; 51 | timeout?: number; 52 | }; 53 | 54 | type InternalWaitForInit = WaitForInit & { 55 | stackTraceError: Error; 56 | }; 57 | 58 | // This is so the stack trace the developer sees is one that's 59 | // closer to their code (because async stack traces are hard to follow). 60 | function copyStackTrace(target, source) { 61 | target.stack = source.stack.replace(source.message, target.message); 62 | } 63 | 64 | function waitFor( 65 | callback: WaitForCallback, 66 | { 67 | interval = DEFAULT_INTERVAL, 68 | onTimeout = error => error, 69 | showOriginalStackTrace = false, 70 | stackTraceError, 71 | timeout = DEFAULT_TIMEOUT 72 | }: InternalWaitForInit 73 | ): Promise { 74 | if (typeof callback !== 'function') { 75 | throw new TypeError('Received `callback` arg must be a function'); 76 | } 77 | 78 | return new Promise(async (resolve, reject) => { 79 | let lastError: unknown; 80 | let intervalId: ReturnType; 81 | let finished = false; 82 | let promiseStatus = 'idle'; 83 | 84 | const overallTimeoutTimer = globalSetTimeout(handleTimeout, timeout); 85 | 86 | const usingJestFakeTimers = jestFakeTimersAreEnabled(); 87 | 88 | if (usingJestFakeTimers) { 89 | checkCallback(); 90 | 91 | // this is a dangerous rule to disable because it could lead to an 92 | // infinite loop. However, eslint isn't smart enough to know that we're 93 | // setting finished inside `onDone` which will be called when we're done 94 | // waiting or when we've timed out. 95 | // eslint-disable-next-line no-unmodified-loop-condition 96 | while (!finished) { 97 | if (!jestFakeTimersAreEnabled()) { 98 | const error = new Error( 99 | `Changed from using fake timers to real timers while using waitFor. This is not allowed and will result in very strange behavior. Please ensure you're awaiting all async things your test is doing before changing to real timers. For more info, please go to https://github.com/testing-library/dom-testing-library/issues/830` 100 | ); 101 | 102 | if (!showOriginalStackTrace) { 103 | copyStackTrace(error, stackTraceError); 104 | } 105 | 106 | reject(error); 107 | 108 | return; 109 | } 110 | 111 | // we *could* (maybe should?) use `advanceTimersToNextTimer` but it's 112 | // possible that could make this loop go on forever if someone is using 113 | // third party code that's setting up recursive timers so rapidly that 114 | // the user's timer's don't get a chance to resolve. So we'll advance 115 | // by an interval instead. (We have a test for this case). 116 | jest.advanceTimersByTime(interval); 117 | 118 | // It's really important that checkCallback is run *before* we flush 119 | // in-flight promises. To be honest, I'm not sure why, and I can't quite 120 | // think of a way to reproduce the problem in a test, but I spent 121 | // an entire day banging my head against a wall on this. 122 | checkCallback(); 123 | 124 | if (finished) { 125 | break; 126 | } 127 | 128 | // In this rare case, we *need* to wait for in-flight promises 129 | // to resolve before continuing. We don't need to take advantage 130 | // of parallelization so we're fine. 131 | // https://stackoverflow.com/a/59243586/971592 132 | // eslint-disable-next-line no-await-in-loop 133 | await new Promise(r => { 134 | globalSetTimeout(r, 0); 135 | 136 | jest.advanceTimersByTime(0); 137 | }); 138 | } 139 | } else { 140 | intervalId = globalSetInterval(checkRealTimersCallback, interval); 141 | 142 | checkCallback(); 143 | } 144 | 145 | function onDone(error, result) { 146 | finished = true; 147 | 148 | clearTimeout(overallTimeoutTimer); 149 | 150 | if (!usingJestFakeTimers) { 151 | clearInterval(intervalId); 152 | } 153 | 154 | if (error) { 155 | reject(error); 156 | } else { 157 | resolve(result); 158 | } 159 | } 160 | 161 | function checkRealTimersCallback() { 162 | if (jestFakeTimersAreEnabled()) { 163 | const error = new Error( 164 | `Changed from using real timers to fake timers while using waitFor. This is not allowed and will result in very strange behavior. Please ensure you're awaiting all async things your test is doing before changing to fake timers. For more info, please go to https://github.com/testing-library/dom-testing-library/issues/830` 165 | ); 166 | 167 | if (!showOriginalStackTrace) { 168 | copyStackTrace(error, stackTraceError); 169 | } 170 | 171 | return reject(error); 172 | } else { 173 | return checkCallback(); 174 | } 175 | } 176 | 177 | function checkCallback() { 178 | if (promiseStatus === 'pending') { 179 | return; 180 | } 181 | 182 | try { 183 | const result = callback(); 184 | 185 | if (typeof result?.then === 'function') { 186 | promiseStatus = 'pending'; 187 | 188 | result.then( 189 | resolvedValue => { 190 | promiseStatus = 'resolved'; 191 | 192 | onDone(null, resolvedValue); 193 | }, 194 | rejectedValue => { 195 | promiseStatus = 'rejected'; 196 | lastError = rejectedValue; 197 | } 198 | ); 199 | } else { 200 | onDone(null, result); 201 | } 202 | // If `callback` throws, wait for the next mutation, interval, or timeout. 203 | } catch (error) { 204 | // Save the most recent callback error to reject the promise with it in the event of a timeout 205 | lastError = error; 206 | } 207 | } 208 | 209 | function handleTimeout() { 210 | let error; 211 | 212 | if (lastError) { 213 | error = lastError; 214 | 215 | if (!showOriginalStackTrace) { 216 | copyStackTrace(error, stackTraceError); 217 | } 218 | } else { 219 | error = new Error('Timed out in waitFor.'); 220 | 221 | if (!showOriginalStackTrace) { 222 | copyStackTrace(error, stackTraceError); 223 | } 224 | } 225 | 226 | onDone(onTimeout(error), null); 227 | } 228 | }); 229 | } 230 | 231 | function waitForWrapper(callback: WaitForCallback, options?: WaitForInit): Promise { 232 | // create the error here so its stack trace is as close to the 233 | // calling code as possible 234 | const stackTraceError = new Error('STACK_TRACE_MESSAGE'); 235 | 236 | return waitFor(callback, { stackTraceError, ...options }); 237 | } 238 | 239 | export default waitForWrapper; 240 | 241 | /* 242 | eslint 243 | max-lines-per-function: ["error", {"max": 200}], 244 | */ 245 | -------------------------------------------------------------------------------- /__tests__/directLineStreaming/__setup__/mockObserver.ts: -------------------------------------------------------------------------------- 1 | import type { Observer, Subscription } from './types/Observable'; 2 | 3 | // "error" is actually "any". 4 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 5 | type Observation = 6 | | [number, 'complete'] 7 | | [number, 'error', any] 8 | | [number, 'next', T] 9 | | [number, 'start', Subscription]; 10 | 11 | /** 12 | * Mocks an observer and records all observations. 13 | */ 14 | export default function mockObserver(): Readonly< 15 | Required> & { 16 | observations: ReadonlyArray>; 17 | observe: (observation: Observation) => void; 18 | } 19 | > { 20 | const observe: (observation: Observation) => void = jest.fn(observation => observations.push(observation)); 21 | const observations: Array> = []; 22 | 23 | const complete = jest.fn(() => observe([Date.now(), 'complete'])); 24 | const error = jest.fn(reason => observe([Date.now(), 'error', reason])); 25 | const next = jest.fn(value => observe([Date.now(), 'next', value])); 26 | const start = jest.fn(subscription => observe([Date.now(), 'start', subscription])); 27 | 28 | return Object.freeze({ 29 | complete, 30 | error, 31 | next, 32 | observe, 33 | start, 34 | 35 | get observations() { 36 | return Object.freeze([...observations]); 37 | } 38 | }); 39 | } 40 | -------------------------------------------------------------------------------- /__tests__/directLineStreaming/__setup__/removeInline.ts: -------------------------------------------------------------------------------- 1 | export default function removeInline(array: Array, item: T): Array { 2 | const index = array.indexOf(item); 3 | 4 | ~index && array.splice(index, 1); 5 | 6 | return array; 7 | } 8 | -------------------------------------------------------------------------------- /__tests__/directLineStreaming/__setup__/setupBotProxy.ts: -------------------------------------------------------------------------------- 1 | import createBotProxy from './createBotProxy'; 2 | 3 | type SetupBotProxyInit = Parameters[0]; 4 | type ValueOfPromise = T extends Promise ? V : never; 5 | 6 | type CreateBotProxyReturnValue = ValueOfPromise>; 7 | 8 | let botProxies: CreateBotProxyReturnValue[] = []; 9 | 10 | beforeEach(() => { 11 | botProxies = []; 12 | }); 13 | 14 | export default async function setupBotProxy( 15 | init?: SetupBotProxyInit 16 | ): Promise> { 17 | const botProxy = await createBotProxy(init); 18 | 19 | botProxies.push(botProxy); 20 | 21 | return { 22 | closeAllWebSocketConnections: botProxy.closeAllWebSocketConnections, 23 | directLineURL: botProxy.directLineURL, 24 | directLineStreamingURL: botProxy.directLineStreamingURL 25 | }; 26 | } 27 | 28 | afterEach(() => { 29 | botProxies.map(botProxy => botProxy.cleanUp()); 30 | botProxies.splice(0); 31 | }); 32 | -------------------------------------------------------------------------------- /__tests__/directLineStreaming/__setup__/types/Observable.ts: -------------------------------------------------------------------------------- 1 | // This is a declaration file for a single ES feature. Could contains multiple classes definitions. 2 | /* eslint-disable max-classes-per-file */ 3 | 4 | // Adopted from https://github.com/tc39/proposal-observable. 5 | 6 | /** Receives a completion notification */ 7 | type CompleteFunction = () => void; 8 | 9 | /** Receives the sequence error */ 10 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 11 | type ErrorFunction = (reason: any) => void; 12 | 13 | /** Receives the next value in the sequence */ 14 | type NextFunction = (value: T) => void; 15 | 16 | /** Subscribes to the observable */ 17 | type SubscriberFunction = (observer: SubscriptionObserver) => (() => void) | Subscription | void; 18 | 19 | /** An `Observable` represents a sequence of values which may be observed. */ 20 | declare interface Observable { 21 | // eslint-disable-next-line @typescript-eslint/no-misused-new 22 | new (subscriber: SubscriberFunction): Observable; 23 | 24 | /** Subscribes to the sequence with an observer */ 25 | subscribe(observer: Observer): Subscription; 26 | 27 | /** Subscribes to the sequence with callbacks */ 28 | subscribe(onNext: NextFunction, onError?: ErrorFunction, onComplete?: CompleteFunction): Subscription; 29 | } 30 | 31 | declare class Subscription { 32 | /** A boolean value indicating whether the subscription is closed */ 33 | get closed(): boolean; 34 | 35 | /** Cancels the subscription */ 36 | public unsubscribe(): void; 37 | } 38 | 39 | /** 40 | * An `Observer` is used to receive data from an `Observable`, and is supplied as an argument to `subscribe`. 41 | * 42 | * All methods are optional. 43 | */ 44 | declare class Observer { 45 | /** Receives a completion notification */ 46 | complete?: CompleteFunction; 47 | 48 | /** Receives the sequence error */ 49 | error?: ErrorFunction; 50 | 51 | /** Receives the next value in the sequence */ 52 | next?: NextFunction; 53 | 54 | /** Receives the subscription object when `subscribe` is called */ 55 | start?: (subscription: Subscription) => void; 56 | } 57 | 58 | /** A `SubscriptionObserver` is a normalized `Observer` which wraps the observer object supplied to `subscribe`. */ 59 | declare class SubscriptionObserver { 60 | /** Sends the completion notification */ 61 | complete: CompleteFunction; 62 | 63 | /** Sends the sequence error */ 64 | error: ErrorFunction; 65 | 66 | /** Sends the next value in the sequence */ 67 | next: NextFunction; 68 | 69 | /** A boolean value indicating whether the subscription is closed */ 70 | get closed(): boolean; 71 | } 72 | 73 | export type { Observable, Observer, Subscription, SubscriptionObserver }; 74 | -------------------------------------------------------------------------------- /__tests__/directLineStreaming/connect.fail.story.js: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | 3 | import { ConnectionStatus } from '../../src/directLine'; 4 | import { DirectLineStreaming } from '../../src/directLineStreaming'; 5 | import waitFor from './__setup__/external/testing-library/waitFor'; 6 | import mockObserver from './__setup__/mockObserver'; 7 | import setupBotProxy from './__setup__/setupBotProxy'; 8 | 9 | const TOKEN_URL = 10 | 'https://hawo-mockbot4-token-app.blueriver-ce85e8f0.westus.azurecontainerapps.io/api/token/directlinease?bot=echo%20bot'; 11 | 12 | afterEach(() => jest.useRealTimers()); 13 | 14 | test('connect fail should signal properly', async () => { 15 | jest.useFakeTimers({ now: 0 }); 16 | 17 | const onUpgrade = jest.fn(); 18 | 19 | onUpgrade.mockImplementation((_req, socket) => { 20 | // Kill the socket when it tries to connect. 21 | socket.end(); 22 | 23 | return Date.now(); 24 | }); 25 | 26 | const { domain, token } = await fetch(TOKEN_URL, { method: 'POST' }).then(res => res.json()); 27 | 28 | const { directLineStreamingURL } = await setupBotProxy({ onUpgrade, streamingBotURL: new URL('/', domain).href }); 29 | 30 | // GIVEN: A Direct Line Streaming chat adapter. 31 | const activityObserver = mockObserver(); 32 | const connectionStatusObserver = mockObserver(); 33 | const directLine = new DirectLineStreaming({ domain: directLineStreamingURL, token }); 34 | 35 | directLine.connectionStatus$.subscribe(connectionStatusObserver); 36 | 37 | // --- 38 | 39 | // WHEN: Connect. 40 | const connectTime = Date.now(); 41 | 42 | directLine.activity$.subscribe(activityObserver); 43 | 44 | // THEN: Should try to connect 3 times. 45 | await waitFor(() => expect(onUpgrade).toBeCalledTimes(3), { timeout: 5_000 }); 46 | 47 | // THEN: Should not wait before connecting the first time. 48 | expect(onUpgrade.mock.results[0].value - connectTime).toBeLessThan(3000); 49 | 50 | // THEN: Should wait for 3-15 seconds before connecting the second time. 51 | expect(onUpgrade.mock.results[1].value - onUpgrade.mock.results[0].value).toBeGreaterThanOrEqual(3000); 52 | expect(onUpgrade.mock.results[1].value - onUpgrade.mock.results[0].value).toBeLessThanOrEqual(15000); 53 | 54 | // THEN: Should wait for 3-15 seconds before connecting the third time. 55 | expect(onUpgrade.mock.results[2].value - onUpgrade.mock.results[1].value).toBeGreaterThanOrEqual(3000); 56 | expect(onUpgrade.mock.results[2].value - onUpgrade.mock.results[1].value).toBeLessThanOrEqual(15000); 57 | 58 | // THEN: Should observe "Uninitialized" -> "Connecting" -> "FailedToConnect". 59 | await waitFor(() => 60 | expect(connectionStatusObserver).toHaveProperty('observations', [ 61 | [expect.any(Number), 'next', ConnectionStatus.Uninitialized], 62 | [expect.any(Number), 'next', ConnectionStatus.Connecting], 63 | [expect.any(Number), 'next', ConnectionStatus.FailedToConnect] 64 | ]) 65 | ); 66 | 67 | // --- 68 | 69 | // WHEN: Call reconnect(). 70 | const reconnectTime = Date.now(); 71 | 72 | directLine.reconnect({ 73 | conversationId: directLine.conversationId, 74 | token: directLine.token 75 | }); 76 | 77 | // THEN: Should try to reconnect 3 times again. 78 | await waitFor(() => expect(onUpgrade).toBeCalledTimes(6)); 79 | 80 | // THEN: Should not wait before reconnecting. 81 | // This is because calling reconnect() should not by delayed. 82 | expect(onUpgrade.mock.results[3].value - reconnectTime).toBeLessThan(3000); 83 | 84 | // THEN: Should wait for 3-15 seconds before reconnecting the second time. 85 | expect(onUpgrade.mock.results[4].value - onUpgrade.mock.results[3].value).toBeGreaterThanOrEqual(3000); 86 | expect(onUpgrade.mock.results[4].value - onUpgrade.mock.results[3].value).toBeLessThanOrEqual(15000); 87 | 88 | // THEN: Should wait for 3-15 seconds before reconnecting the third time. 89 | expect(onUpgrade.mock.results[5].value - onUpgrade.mock.results[4].value).toBeGreaterThanOrEqual(3000); 90 | expect(onUpgrade.mock.results[5].value - onUpgrade.mock.results[4].value).toBeLessThanOrEqual(15000); 91 | 92 | // THEN: Should observe "Uninitialized" -> "Connecting" -> "FailedToConnect" -> "Connecting" -> "FailedToConnect". 93 | await waitFor(() => 94 | expect(connectionStatusObserver).toHaveProperty('observations', [ 95 | [expect.any(Number), 'next', ConnectionStatus.Uninitialized], 96 | [expect.any(Number), 'next', ConnectionStatus.Connecting], 97 | [expect.any(Number), 'next', ConnectionStatus.FailedToConnect], 98 | [expect.any(Number), 'next', ConnectionStatus.Connecting], 99 | [expect.any(Number), 'next', ConnectionStatus.FailedToConnect] 100 | ]) 101 | ); 102 | }); 103 | -------------------------------------------------------------------------------- /__tests__/directLineStreaming/connect.success.story.js: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | 3 | import { ConnectionStatus } from '../../src/directLine'; 4 | import { DirectLineStreaming } from '../../src/directLineStreaming'; 5 | import waitFor from './__setup__/external/testing-library/waitFor'; 6 | import mockObserver from './__setup__/mockObserver'; 7 | import setupBotProxy from './__setup__/setupBotProxy'; 8 | 9 | const TOKEN_URL = 10 | 'https://hawo-mockbot4-token-app.blueriver-ce85e8f0.westus.azurecontainerapps.io/api/token/directlinease?bot=echo%20bot'; 11 | 12 | afterEach(() => jest.useRealTimers()); 13 | 14 | test('should connect', async () => { 15 | jest.useFakeTimers({ now: 0 }); 16 | 17 | const { domain, token } = await fetch(TOKEN_URL, { method: 'POST' }).then(res => res.json()); 18 | 19 | const { directLineStreamingURL } = await setupBotProxy({ streamingBotURL: new URL('/', domain).href }); 20 | 21 | // GIVEN: A Direct Line Streaming chat adapter. 22 | const activityObserver = mockObserver(); 23 | const connectionStatusObserver = mockObserver(); 24 | const directLine = new DirectLineStreaming({ domain: directLineStreamingURL, token }); 25 | 26 | directLine.connectionStatus$.subscribe(connectionStatusObserver); 27 | 28 | // --- 29 | 30 | // WHEN: Connect. 31 | directLine.activity$.subscribe(activityObserver); 32 | 33 | // THEN: Should observe "Uninitialized" -> "Connecting" -> "Online". 34 | await waitFor( 35 | () => 36 | expect(connectionStatusObserver).toHaveProperty('observations', [ 37 | [expect.any(Number), 'next', ConnectionStatus.Uninitialized], 38 | [expect.any(Number), 'next', ConnectionStatus.Connecting], 39 | [expect.any(Number), 'next', ConnectionStatus.Online] 40 | ]), 41 | { timeout: 5000 } 42 | ); 43 | 44 | // THEN: Should receive "Hello and welcome!" 45 | await waitFor(() => 46 | expect(activityObserver).toHaveProperty('observations', [ 47 | [expect.any(Number), 'next', expect.activityContaining('Hello and welcome!')] 48 | ]) 49 | ); 50 | }); 51 | -------------------------------------------------------------------------------- /__tests__/directLineStreaming/end.story.js: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | 3 | import { ConnectionStatus } from '../../src/directLine'; 4 | import { DirectLineStreaming } from '../../src/directLineStreaming'; 5 | import waitFor from './__setup__/external/testing-library/waitFor'; 6 | import mockObserver from './__setup__/mockObserver'; 7 | import setupBotProxy from './__setup__/setupBotProxy'; 8 | 9 | const TOKEN_URL = 10 | 'https://hawo-mockbot4-token-app.blueriver-ce85e8f0.westus.azurecontainerapps.io/api/token/directlinease?bot=echo%20bot'; 11 | 12 | afterEach(() => jest.useRealTimers()); 13 | 14 | test('should connect', async () => { 15 | jest.useFakeTimers({ now: 0 }); 16 | 17 | const { domain, token } = await fetch(TOKEN_URL, { method: 'POST' }).then(res => res.json()); 18 | 19 | const { directLineStreamingURL } = await setupBotProxy({ streamingBotURL: new URL('/', domain).href }); 20 | 21 | // GIVEN: A Direct Line Streaming chat adapter. 22 | const activityObserver = mockObserver(); 23 | const connectionStatusObserver = mockObserver(); 24 | const directLine = new DirectLineStreaming({ domain: directLineStreamingURL, token }); 25 | 26 | directLine.connectionStatus$.subscribe(connectionStatusObserver); 27 | 28 | // --- 29 | 30 | // WHEN: Connect. 31 | directLine.activity$.subscribe(activityObserver); 32 | 33 | // THEN: Should observe "Uninitialized" -> "Connecting" -> "Online". 34 | await waitFor( 35 | () => 36 | expect(connectionStatusObserver).toHaveProperty('observations', [ 37 | [expect.any(Number), 'next', ConnectionStatus.Uninitialized], 38 | [expect.any(Number), 'next', ConnectionStatus.Connecting], 39 | [expect.any(Number), 'next', ConnectionStatus.Online] 40 | ]), 41 | { timeout: 5000 } 42 | ); 43 | 44 | // --- 45 | 46 | // WHEN: Call end(). 47 | directLine.end(); 48 | 49 | // THEN: Should observe "Uninitialized" -> "Connecting" -> "Online" -> "Ended" -> Complete. 50 | await waitFor(() => 51 | expect(connectionStatusObserver).toHaveProperty('observations', [ 52 | [expect.any(Number), 'next', ConnectionStatus.Uninitialized], 53 | [expect.any(Number), 'next', ConnectionStatus.Connecting], 54 | [expect.any(Number), 'next', ConnectionStatus.Online], 55 | [expect.any(Number), 'next', ConnectionStatus.Ended], 56 | [expect.any(Number), 'complete'] 57 | ]) 58 | ); 59 | 60 | // --- 61 | 62 | // WHEN: Send a message after disconnection. 63 | const postActivityObserver = mockObserver(); 64 | 65 | directLine 66 | .postActivity({ 67 | text: 'Hello, World!', 68 | type: 'message' 69 | }) 70 | .subscribe(postActivityObserver); 71 | 72 | // THEN: Should fail all postActivity() calls. 73 | await waitFor(() => 74 | expect(postActivityObserver).toHaveProperty('observations', [[expect.any(Number), 'error', expect.any(Error)]]) 75 | ); 76 | 77 | // THEN: Should complete activity$. 78 | await waitFor(() => 79 | expect(activityObserver).toHaveProperty('observations', [ 80 | [expect.any(Number), 'next', expect.activityContaining('Hello and welcome!')], 81 | [expect.any(Number), 'complete'] 82 | ]) 83 | ); 84 | 85 | // THEN: Call reconnect() should throw. 86 | expect(() => 87 | directLine.reconnect({ 88 | conversationId: directLine.conversationId, 89 | token: directLine.token 90 | }) 91 | ).toThrow('Connection has ended.'); 92 | }); 93 | -------------------------------------------------------------------------------- /__tests__/directLineStreaming/options.networkInformation.story.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import fetch from 'node-fetch'; 4 | 5 | import { ConnectionStatus } from '../../src/directLine'; 6 | import { DirectLineStreaming } from '../../src/directLineStreaming'; 7 | import waitFor from './__setup__/external/testing-library/waitFor'; 8 | import mockObserver from './__setup__/mockObserver'; 9 | import setupBotProxy from './__setup__/setupBotProxy'; 10 | 11 | type MockObserver = ReturnType; 12 | type ResultOfPromise = T extends PromiseLike ? P : never; 13 | 14 | const TOKEN_URL = 15 | 'https://hawo-mockbot4-token-app.blueriver-ce85e8f0.westus.azurecontainerapps.io/api/token/directlinease?bot=echo%20bot'; 16 | 17 | jest.setTimeout(10_000); 18 | 19 | // GIVEN: A Direct Line Streaming chat adapter with Network Information API. 20 | describe('Direct Line Streaming chat adapter with Network Information API', () => { 21 | let activityObserver: MockObserver; 22 | let botProxy: ResultOfPromise>; 23 | let connectionStatusObserver: MockObserver; 24 | let directLine: DirectLineStreaming; 25 | 26 | beforeEach(async () => { 27 | jest.useFakeTimers({ now: 0 }); 28 | 29 | const networkInformation = new EventTarget(); 30 | let type: string = 'wifi'; 31 | 32 | Object.defineProperty(networkInformation, 'type', { 33 | get() { 34 | return type; 35 | }, 36 | set(value: string) { 37 | if (type !== value) { 38 | type = value; 39 | networkInformation.dispatchEvent(new Event('change')); 40 | } 41 | } 42 | }); 43 | 44 | // Node.js 22.x has global.navigator, but Node.js 18.x and 20.x don't. 45 | if (!global.navigator) { 46 | (global as any).navigator = {}; 47 | } 48 | 49 | (global as any).navigator.connection = networkInformation; 50 | 51 | const { domain, token } = await fetch(TOKEN_URL, { method: 'POST' }).then(res => res.json()); 52 | 53 | const botProxy = await setupBotProxy({ streamingBotURL: new URL('/', domain).href }); 54 | 55 | activityObserver = mockObserver(); 56 | connectionStatusObserver = mockObserver(); 57 | directLine = new DirectLineStreaming({ 58 | domain: botProxy.directLineStreamingURL, 59 | networkInformation: (navigator as any).connection, 60 | token 61 | }); 62 | 63 | directLine.connectionStatus$.subscribe(connectionStatusObserver); 64 | }); 65 | 66 | afterEach(() => { 67 | directLine.end(); 68 | 69 | jest.useRealTimers(); 70 | }); 71 | 72 | describe('when connect', () => { 73 | // WHEN: Connect. 74 | beforeEach(() => directLine.activity$.subscribe(activityObserver)); 75 | 76 | // THEN: Should observe "Uninitialized" -> "Connecting" -> "Online". 77 | test('should observe "Uninitialized" -> "Connecting" -> "Online"', () => 78 | waitFor( 79 | () => 80 | expect(connectionStatusObserver).toHaveProperty('observations', [ 81 | [expect.any(Number), 'next', ConnectionStatus.Uninitialized], 82 | [expect.any(Number), 'next', ConnectionStatus.Connecting], 83 | [expect.any(Number), 'next', ConnectionStatus.Online] 84 | ]), 85 | { timeout: 5_000 } 86 | )); 87 | 88 | // WHEN: Connection status become "Online". 89 | describe('after online', () => { 90 | beforeEach(() => 91 | waitFor( 92 | () => 93 | expect(connectionStatusObserver.observe).toHaveBeenLastCalledWith([ 94 | expect.any(Number), 95 | 'next', 96 | ConnectionStatus.Online 97 | ]), 98 | { timeout: 5_000 } 99 | ) 100 | ); 101 | 102 | // THEN: Should receive "Hello and welcome!" 103 | test('should receive "Hello and welcome!"', () => 104 | waitFor( 105 | () => 106 | expect(activityObserver).toHaveProperty('observations', [ 107 | [expect.any(Number), 'next', expect.activityContaining('Hello and welcome!')] 108 | ]), 109 | { timeout: 5_000 } 110 | )); 111 | 112 | // WHEN: "change" event is received. 113 | describe('when "change" event is received', () => { 114 | beforeEach(() => { 115 | (navigator as any).connection.type = 'bluetooth'; 116 | }); 117 | 118 | // THEN: Should observe "Connecting" -> "Online" again. 119 | test('should observe ... -> "Connecting" -> "Online"', () => 120 | waitFor( 121 | () => 122 | expect(connectionStatusObserver).toHaveProperty('observations', [ 123 | [expect.any(Number), 'next', ConnectionStatus.Uninitialized], 124 | [expect.any(Number), 'next', ConnectionStatus.Connecting], 125 | [expect.any(Number), 'next', ConnectionStatus.Online], 126 | [expect.any(Number), 'next', ConnectionStatus.Connecting], 127 | [expect.any(Number), 'next', ConnectionStatus.Online] 128 | ]), 129 | { timeout: 5_000 } 130 | )); 131 | }); 132 | }); 133 | }); 134 | }); 135 | -------------------------------------------------------------------------------- /__tests__/directLineStreaming/postActivity.fail.story.js: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | 3 | import { ConnectionStatus } from '../../src/directLine'; 4 | import { DirectLineStreaming } from '../../src/directLineStreaming'; 5 | import mockObserver from './__setup__/mockObserver'; 6 | import setupBotProxy from './__setup__/setupBotProxy'; 7 | import waitFor from './__setup__/external/testing-library/waitFor'; 8 | 9 | const TOKEN_URL = 'https://hawo-mockbot4-token-app.blueriver-ce85e8f0.westus.azurecontainerapps.io/api/token/directlinease?bot=echo%20bot'; 10 | 11 | afterEach(() => jest.useRealTimers()); 12 | 13 | test('should send activity', async () => { 14 | jest.useFakeTimers({ now: 0 }); 15 | 16 | const onWebSocketSendMessage = jest.fn(); 17 | 18 | onWebSocketSendMessage.mockImplementation((data, socket, req, next) => next(data, socket, req)); 19 | 20 | const { domain, token } = await fetch(TOKEN_URL, { method: 'POST' }).then(res => res.json()); 21 | 22 | const { directLineStreamingURL } = await setupBotProxy({ onWebSocketSendMessage, streamingBotURL: new URL('/', domain).href }); 23 | 24 | // GIVEN: A Direct Line Streaming chat adapter. 25 | const activityObserver = mockObserver(); 26 | const connectionStatusObserver = mockObserver(); 27 | const directLine = new DirectLineStreaming({ domain: directLineStreamingURL, token }); 28 | 29 | directLine.connectionStatus$.subscribe(connectionStatusObserver); 30 | 31 | // --- 32 | 33 | // WHEN: Connect. 34 | directLine.activity$.subscribe(activityObserver); 35 | 36 | // THEN: Should observe "Uninitialized" -> "Connecting" -> "Online". 37 | await waitFor( 38 | () => 39 | expect(connectionStatusObserver).toHaveProperty('observations', [ 40 | [expect.any(Number), 'next', ConnectionStatus.Uninitialized], 41 | [expect.any(Number), 'next', ConnectionStatus.Connecting], 42 | [expect.any(Number), 'next', ConnectionStatus.Online] 43 | ]), 44 | { timeout: 5000 } 45 | ); 46 | 47 | // THEN: Should receive "Hello and welcome!" 48 | await waitFor(() => 49 | expect(activityObserver).toHaveProperty('observations', [ 50 | [expect.any(Number), 'next', expect.activityContaining('Hello and welcome!')] 51 | ]) 52 | ); 53 | 54 | // --- 55 | 56 | // GIVEN: Kill connection on next Web Socket message. 57 | // This mimic TCP behavior that disconnection may not be detected until next send. 58 | onWebSocketSendMessage.mockClear(); 59 | onWebSocketSendMessage.mockImplementationOnce((_data, socket) => socket.close()); 60 | 61 | // WHEN: Send a message to the bot. 62 | const postActivityObserver = mockObserver(); 63 | 64 | directLine 65 | .postActivity({ 66 | text: 'Hello, World!', 67 | type: 'message' 68 | }) 69 | .subscribe(postActivityObserver); 70 | 71 | // THEN: Should send through Web Socket. 72 | await waitFor(() => expect(onWebSocketSendMessage).toBeCalled()); 73 | 74 | // THEN: Should fail the call. 75 | await waitFor(() => 76 | expect(postActivityObserver).toHaveProperty('observations', [[expect.any(Number), 'error', expect.any(Error)]]) 77 | ); 78 | 79 | // THEN: Should observe "Connecting" -> "Online" because the chat adapter should reconnect. 80 | await waitFor( 81 | () => 82 | expect(connectionStatusObserver).toHaveProperty('observations', [ 83 | [expect.any(Number), 'next', ConnectionStatus.Uninitialized], 84 | [expect.any(Number), 'next', ConnectionStatus.Connecting], 85 | [expect.any(Number), 'next', ConnectionStatus.Online], 86 | [expect.any(Number), 'next', ConnectionStatus.Connecting], 87 | [expect.any(Number), 'next', ConnectionStatus.Online] 88 | ]), 89 | { timeout: 5_000 } 90 | ); 91 | }, 15000); 92 | -------------------------------------------------------------------------------- /__tests__/directLineStreaming/postActivity.success.story.js: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | 3 | import { ConnectionStatus } from '../../src/directLine'; 4 | import { DirectLineStreaming } from '../../src/directLineStreaming'; 5 | import activityTimestampComparer from './__setup__/activityTimestampComparer'; 6 | import mockObserver from './__setup__/mockObserver'; 7 | import setupBotProxy from './__setup__/setupBotProxy'; 8 | import waitFor from './__setup__/external/testing-library/waitFor'; 9 | 10 | const TOKEN_URL = 'https://hawo-mockbot4-token-app.blueriver-ce85e8f0.westus.azurecontainerapps.io/api/token/directlinease?bot=echo%20bot'; 11 | 12 | afterEach(() => jest.useRealTimers()); 13 | 14 | test('should send activity', async () => { 15 | jest.useFakeTimers({ now: 0 }); 16 | 17 | const { domain, token } = await fetch(TOKEN_URL, { method: 'POST' }).then(res => res.json()); 18 | 19 | const { directLineStreamingURL } = await setupBotProxy({ streamingBotURL: new URL('/', domain).href }); 20 | 21 | // GIVEN: A Direct Line Streaming chat adapter. 22 | const activityObserver = mockObserver(); 23 | const connectionStatusObserver = mockObserver(); 24 | const directLine = new DirectLineStreaming({ domain: directLineStreamingURL, token }); 25 | 26 | directLine.connectionStatus$.subscribe(connectionStatusObserver); 27 | 28 | // --- 29 | 30 | // WHEN: Connect. 31 | directLine.activity$.subscribe(activityObserver); 32 | 33 | // THEN: Should observe "Uninitialized" -> "Connecting" -> "Online". 34 | await waitFor( 35 | () => 36 | expect(connectionStatusObserver).toHaveProperty('observations', [ 37 | [expect.any(Number), 'next', ConnectionStatus.Uninitialized], 38 | [expect.any(Number), 'next', ConnectionStatus.Connecting], 39 | [expect.any(Number), 'next', ConnectionStatus.Online] 40 | ]), 41 | { timeout: 5000 } 42 | ); 43 | 44 | // THEN: Should receive "Hello and welcome!" 45 | await waitFor(() => 46 | expect(activityObserver).toHaveProperty('observations', [ 47 | [expect.any(Number), 'next', expect.activityContaining('Hello and welcome!')] 48 | ]) 49 | ); 50 | 51 | // --- 52 | 53 | // WHEN: Send a message to the bot. 54 | const postActivityObserver = mockObserver(); 55 | 56 | directLine 57 | .postActivity({ 58 | text: 'Hello, World!', 59 | type: 'message' 60 | }) 61 | .subscribe(postActivityObserver); 62 | 63 | // THEN: Should send successfully and completed the observable. 64 | await waitFor(() => 65 | expect(postActivityObserver).toHaveProperty('observations', [ 66 | [expect.any(Number), 'next', expect.any(String)][(expect.any(Number), 'complete')] 67 | ]) 68 | ); 69 | 70 | // THEN: Bot should reply and the activity should echo back. 71 | await waitFor( 72 | () => 73 | expect([...activityObserver.observations].sort(([, , x], [, , y]) => activityTimestampComparer(x, y))).toEqual([ 74 | [expect.any(Number), 'next', expect.activityContaining('Hello and welcome!')], 75 | [ 76 | expect.any(Number), 77 | 'next', 78 | expect.activityContaining('Hello, World!', { 79 | id: postActivityObserver.observations[0][2] 80 | }) 81 | ], 82 | [expect.any(Number), 'next', expect.activityContaining('Echo: Hello, World!')] 83 | ]), 84 | { timeout: 5000 } 85 | ); 86 | }, 15000); 87 | -------------------------------------------------------------------------------- /__tests__/directLineStreaming/retryConnect.fail.story.js: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | 3 | import { ConnectionStatus } from '../../src/directLine'; 4 | import { DirectLineStreaming } from '../../src/directLineStreaming'; 5 | import waitFor from './__setup__/external/testing-library/waitFor'; 6 | import mockObserver from './__setup__/mockObserver'; 7 | import setupBotProxy from './__setup__/setupBotProxy'; 8 | 9 | const TOKEN_URL = 10 | 'https://hawo-mockbot4-token-app.blueriver-ce85e8f0.westus.azurecontainerapps.io/api/token/directlinease?bot=echo%20bot'; 11 | 12 | jest.setTimeout(15000); 13 | 14 | afterEach(() => jest.useRealTimers()); 15 | 16 | test('reconnect fail should stop', async () => { 17 | jest.useFakeTimers({ now: 0 }); 18 | 19 | const onUpgrade = jest.fn(); 20 | 21 | onUpgrade.mockImplementation((req, socket, head, next) => next(req, socket, head)); 22 | 23 | const { domain, token } = await fetch(TOKEN_URL, { method: 'POST' }).then(res => res.json()); 24 | 25 | const { closeAllWebSocketConnections, directLineStreamingURL } = await setupBotProxy({ 26 | onUpgrade, 27 | streamingBotURL: new URL('/', domain).href 28 | }); 29 | 30 | // GIVEN: A Direct Line Streaming chat adapter. 31 | const activityObserver = mockObserver(); 32 | const connectionStatusObserver = mockObserver(); 33 | const directLine = new DirectLineStreaming({ domain: directLineStreamingURL, token }); 34 | 35 | directLine.connectionStatus$.subscribe(connectionStatusObserver); 36 | 37 | // --- 38 | 39 | // WHEN: Connect. 40 | directLine.activity$.subscribe(activityObserver); 41 | 42 | // THEN: Server should observe one Web Socket connection. 43 | await waitFor(() => expect(onUpgrade).toBeCalledTimes(1)); 44 | 45 | // THEN: Should observe "Uninitialized" -> "Connecting" -> "Online". 46 | await waitFor( 47 | () => { 48 | expect(connectionStatusObserver).toHaveProperty('observations', [ 49 | [expect.any(Number), 'next', ConnectionStatus.Uninitialized], 50 | [expect.any(Number), 'next', ConnectionStatus.Connecting], 51 | [expect.any(Number), 'next', ConnectionStatus.Online] 52 | ]); 53 | }, 54 | { timeout: 5000 } 55 | ); 56 | 57 | // --- 58 | 59 | // GIVEN: Tick for 1 minute. DLJS will consider this connection as stable and reset retry count to 3. 60 | jest.advanceTimersByTime(60000); 61 | 62 | // --- 63 | 64 | // WHEN: Kill all future Web Socket connections. 65 | onUpgrade.mockClear(); 66 | onUpgrade.mockImplementation((_, socket) => { 67 | socket.end(); 68 | 69 | // HACK: Returns the time when the connection is made, so we can expect() it later. 70 | return Date.now(); 71 | }); 72 | 73 | // WHEN: Forcibly close all Web Sockets to trigger a reconnect. 74 | const disconnectTime = Date.now(); 75 | 76 | closeAllWebSocketConnections(); 77 | 78 | // THEN: Server should observe three Web Socket connections. 79 | await waitFor(() => expect(onUpgrade).toBeCalledTimes(3), { timeout: 5000 }); 80 | 81 | // THEN: Should not wait before reconnecting the first time. 82 | // This is because the connection has been established for more than 1 minute and is considered stable. 83 | expect(onUpgrade.mock.results[0].value - disconnectTime).toBeLessThan(3000); 84 | 85 | // THEN: Should wait for 3-15 seconds before reconnecting the second time. 86 | expect(onUpgrade.mock.results[1].value - onUpgrade.mock.results[0].value).toBeGreaterThanOrEqual(3000); 87 | expect(onUpgrade.mock.results[1].value - onUpgrade.mock.results[0].value).toBeLessThanOrEqual(15000); 88 | 89 | // THEN: Should wait for 3-15 seconds before reconnecting the third time. 90 | expect(onUpgrade.mock.results[2].value - onUpgrade.mock.results[1].value).toBeGreaterThanOrEqual(3000); 91 | expect(onUpgrade.mock.results[2].value - onUpgrade.mock.results[1].value).toBeLessThanOrEqual(15000); 92 | 93 | // THEN: Should observe "Uninitialized" -> "Connecting" -> "Online" -> "Connecting" -> "FailedToConnect". 94 | await waitFor(() => { 95 | expect(connectionStatusObserver).toHaveProperty('observations', [ 96 | [expect.any(Number), 'next', ConnectionStatus.Uninitialized], 97 | [expect.any(Number), 'next', ConnectionStatus.Connecting], 98 | [expect.any(Number), 'next', ConnectionStatus.Online], 99 | [expect.any(Number), 'next', ConnectionStatus.Connecting], 100 | [expect.any(Number), 'next', ConnectionStatus.FailedToConnect] 101 | ]); 102 | }); 103 | 104 | // --- 105 | 106 | // WHEN: Call reconnect(); 107 | const reconnectTime = Date.now(); 108 | 109 | directLine.reconnect({ 110 | conversationId: directLine.conversationId, 111 | token: directLine.token 112 | }); 113 | 114 | // THEN: Server should observe 3 connections again. 115 | await waitFor(() => expect(onUpgrade).toBeCalledTimes(6), { timeout: 5000 }); 116 | 117 | // THEN: Should not wait before reconnecting. 118 | // This is because calling reconnect() should not by delayed. 119 | expect(onUpgrade.mock.results[3].value - reconnectTime).toBeLessThan(3000); 120 | 121 | // THEN: Should wait for 3-15 seconds before reconnecting the second time. 122 | expect(onUpgrade.mock.results[4].value - onUpgrade.mock.results[3].value).toBeGreaterThanOrEqual(3000); 123 | expect(onUpgrade.mock.results[4].value - onUpgrade.mock.results[3].value).toBeLessThanOrEqual(15000); 124 | 125 | // THEN: Should wait for 3-15 seconds before reconnecting the third time. 126 | expect(onUpgrade.mock.results[5].value - onUpgrade.mock.results[4].value).toBeGreaterThanOrEqual(3000); 127 | expect(onUpgrade.mock.results[5].value - onUpgrade.mock.results[4].value).toBeLessThanOrEqual(15000); 128 | 129 | // THEN: Should observe ... -> "Connecting" -> "FailedToConnect". 130 | await waitFor(() => { 131 | expect(connectionStatusObserver).toHaveProperty('observations', [ 132 | [expect.any(Number), 'next', ConnectionStatus.Uninitialized], 133 | [expect.any(Number), 'next', ConnectionStatus.Connecting], 134 | [expect.any(Number), 'next', ConnectionStatus.Online], 135 | [expect.any(Number), 'next', ConnectionStatus.Connecting], 136 | [expect.any(Number), 'next', ConnectionStatus.FailedToConnect], 137 | [expect.any(Number), 'next', ConnectionStatus.Connecting], 138 | [expect.any(Number), 'next', ConnectionStatus.FailedToConnect] 139 | ]); 140 | }); 141 | }); 142 | -------------------------------------------------------------------------------- /__tests__/directLineStreaming/retryConnect.success.story.js: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | 3 | import { ConnectionStatus } from '../../src/directLine'; 4 | import { DirectLineStreaming } from '../../src/directLineStreaming'; 5 | import activityTimestampComparer from './__setup__/activityTimestampComparer'; 6 | import waitFor from './__setup__/external/testing-library/waitFor'; 7 | import mockObserver from './__setup__/mockObserver'; 8 | import setupBotProxy from './__setup__/setupBotProxy'; 9 | 10 | const TOKEN_URL = 11 | 'https://hawo-mockbot4-token-app.blueriver-ce85e8f0.westus.azurecontainerapps.io/api/token/directlinease?bot=echo%20bot'; 12 | 13 | afterEach(() => jest.useRealTimers()); 14 | 15 | test.each([['with stable connection'], ['without stable connection']])( 16 | '%s reconnect successful should continue to function properly', 17 | async scenario => { 18 | jest.useFakeTimers({ now: 0 }); 19 | 20 | const onUpgrade = jest.fn((req, socket, head, next) => { 21 | next(req, socket, head); 22 | 23 | // HACK: Returns the time when the connection is made. 24 | return Date.now(); 25 | }); 26 | 27 | const { domain, token } = await fetch(TOKEN_URL, { method: 'POST' }).then(res => res.json()); 28 | 29 | const { closeAllWebSocketConnections, directLineStreamingURL } = await setupBotProxy({ 30 | onUpgrade, 31 | streamingBotURL: new URL('/', domain).href 32 | }); 33 | 34 | // GIVEN: A Direct Line Streaming chat adapter. 35 | const activityObserver = mockObserver(); 36 | const connectionStatusObserver = mockObserver(); 37 | const directLine = new DirectLineStreaming({ domain: directLineStreamingURL, token }); 38 | 39 | directLine.connectionStatus$.subscribe(connectionStatusObserver); 40 | 41 | // --- 42 | 43 | // WHEN: Connect. 44 | directLine.activity$.subscribe(activityObserver); 45 | 46 | // THEN: Should observe "Uninitialized" -> "Connecting" -> "Online". 47 | await waitFor( 48 | () => { 49 | expect(connectionStatusObserver).toHaveProperty('observations', [ 50 | [expect.any(Number), 'next', ConnectionStatus.Uninitialized], 51 | [expect.any(Number), 'next', ConnectionStatus.Connecting], 52 | [expect.any(Number), 'next', ConnectionStatus.Online] 53 | ]); 54 | }, 55 | { timeout: 5000 } 56 | ); 57 | 58 | // THEN: Should made the connection. 59 | expect(onUpgrade).toBeCalledTimes(1); 60 | 61 | // --- 62 | 63 | if (scenario === 'with stable connection') { 64 | // GIVEN: Tick for 1 minute. DLJS will consider this connection as stable and reset retry count to 3. 65 | jest.advanceTimersByTime(60000); 66 | } 67 | 68 | // WHEN: All Web Sockets are forcibly closed. 69 | const disconnectTime = Date.now(); 70 | 71 | closeAllWebSocketConnections(); 72 | 73 | // THEN: Should observe "Uninitialized" -> "Connecting" -> "Online" -> "Connecting" -> "Online". 74 | await waitFor( 75 | () => { 76 | expect(connectionStatusObserver).toHaveProperty('observations', [ 77 | [expect.any(Number), 'next', ConnectionStatus.Uninitialized], 78 | [expect.any(Number), 'next', ConnectionStatus.Connecting], 79 | [expect.any(Number), 'next', ConnectionStatus.Online], 80 | [expect.any(Number), 'next', ConnectionStatus.Connecting], 81 | [expect.any(Number), 'next', ConnectionStatus.Online] 82 | ]); 83 | }, 84 | { timeout: 5000 } 85 | ); 86 | 87 | // THEN: "Connecting" should happen immediately after connection is closed. 88 | const connectingTime = connectionStatusObserver.observations[3][0]; 89 | 90 | expect(connectingTime - disconnectTime).toBeLessThan(3000); 91 | 92 | if (scenario === 'with stable connection') { 93 | // THEN: Should reconnect immediately. 94 | expect(onUpgrade).toBeCalledTimes(2); 95 | expect(onUpgrade.mock.results[1].value - disconnectTime).toBeLessThan(3000); 96 | } else { 97 | // THEN: Should reconnect after 3-15 seconds. 98 | expect(onUpgrade).toBeCalledTimes(2); 99 | expect(onUpgrade.mock.results[1].value - disconnectTime).toBeGreaterThanOrEqual(3000); 100 | expect(onUpgrade.mock.results[1].value - disconnectTime).toBeLessThanOrEqual(15000); 101 | } 102 | 103 | // --- 104 | 105 | // WHEN: Send a message to the bot after reconnected. 106 | const postActivityObserver = mockObserver(); 107 | 108 | directLine 109 | .postActivity({ 110 | text: 'Hello, World!', 111 | type: 'message' 112 | }) 113 | .subscribe(postActivityObserver); 114 | 115 | // THEN: Should send successfully and completed the observable. 116 | await waitFor(() => 117 | expect(postActivityObserver).toHaveProperty('observations', [ 118 | [expect.any(Number), 'next', expect.any(String)], 119 | [expect.any(Number), 'complete'] 120 | ]) 121 | ); 122 | 123 | // THEN: Bot should reply and the activity should echo back. 124 | await waitFor( 125 | () => { 126 | expect([...activityObserver.observations].sort(([, , x], [, , y]) => activityTimestampComparer(x, y))).toEqual([ 127 | [expect.any(Number), 'next', expect.activityContaining('Hello and welcome!')], 128 | [expect.any(Number), 'next', expect.activityContaining('Hello and welcome!')], 129 | [ 130 | expect.any(Number), 131 | 'next', 132 | expect.activityContaining('Hello, World!', { id: postActivityObserver.observations[0][2] }) 133 | ], 134 | [expect.any(Number), 'next', expect.activityContaining('Echo: Hello, World!')] 135 | ]); 136 | }, 137 | { timeout: 5000 } 138 | ); 139 | }, 140 | 15000 141 | ); 142 | -------------------------------------------------------------------------------- /__tests__/happy.conversationUpdate.js: -------------------------------------------------------------------------------- 1 | /** @jest-environment ./__tests__/setup/jsdomEnvironmentWithProxy */ 2 | 3 | import 'dotenv/config'; 4 | 5 | import onErrorResumeNext from 'on-error-resume-next'; 6 | 7 | import { timeouts } from './constants.json'; 8 | import * as createDirectLine from './setup/createDirectLine'; 9 | import waitForBotToRespond from './setup/waitForBotToRespond'; 10 | import waitForConnected from './setup/waitForConnected'; 11 | 12 | // Skipping because the bot at dljstestbot.azurewebsites.net is not available. 13 | describe.skip('Happy path', () => { 14 | let unsubscribes; 15 | 16 | beforeEach(() => unsubscribes = []); 17 | afterEach(() => unsubscribes.forEach(fn => onErrorResumeNext(fn))); 18 | 19 | describe('should receive the welcome message from bot', () => { 20 | let directLine; 21 | 22 | describe('using REST', () => { 23 | beforeEach(() => jest.setTimeout(timeouts.rest)); 24 | 25 | test('with token', async () => { 26 | directLine = await createDirectLine.forREST({ token: true }); 27 | }); 28 | }); 29 | 30 | test('using Streaming Extensions', async () => { 31 | jest.setTimeout(timeouts.webSocket); 32 | directLine = await createDirectLine.forStreamingExtensions(); 33 | }); 34 | 35 | describe('using Web Socket', () => { 36 | beforeEach(() => jest.setTimeout(timeouts.webSocket)); 37 | 38 | test('with token', async () => { 39 | directLine = await createDirectLine.forWebSocket({ token: true }); 40 | }); 41 | }); 42 | 43 | afterEach(async () => { 44 | // If directLine object is undefined, that means the test is failing. 45 | if (!directLine) { return; } 46 | 47 | unsubscribes.push(directLine.end.bind(directLine)); 48 | 49 | await waitForBotToRespond(directLine, ({ text }) => text === 'Welcome') 50 | }); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /__tests__/happy.dlstreamConnection.js: -------------------------------------------------------------------------------- 1 | /** @jest-environment ./__tests__/setup/jsdomEnvironmentWithProxy */ 2 | 3 | import 'dotenv/config'; 4 | 5 | import onErrorResumeNext from 'on-error-resume-next'; 6 | 7 | import { timeouts } from './constants.json'; 8 | import * as createDirectLine from './setup/createDirectLine'; 9 | import waitForConnected from './setup/waitForConnected'; 10 | 11 | // TODO: Need more realistic testing. 12 | // - Able to connect to a Web Socket server 13 | // - Make sure after `end` is called, the client will not reconnect 14 | // - If the connection is disrupted, make sure the client will reconnect 15 | // - Use a fake timer to speed up the test 16 | 17 | // Skipping because the bot at dljstestbot.azurewebsites.net is not available. 18 | describe.skip('test dl streaming end', () => { 19 | let unsubscribes; 20 | let directLine; 21 | const ConnectionStatusEnd = 5; 22 | beforeEach(() => unsubscribes = []); 23 | afterEach(() => unsubscribes.forEach(fn => onErrorResumeNext(fn))); 24 | test('using Streaming Extensions', async () => { 25 | jest.setTimeout(30000); 26 | directLine = await createDirectLine.forStreamingExtensions(); 27 | unsubscribes.push(directLine.end.bind(directLine)); 28 | unsubscribes.push(await waitForConnected(directLine)); 29 | await new Promise(resolve => setTimeout(resolve, 2000)); 30 | directLine.end(); 31 | expect(directLine.connectionStatus$.getValue()).toBe(ConnectionStatusEnd); 32 | }) 33 | }); 34 | -------------------------------------------------------------------------------- /__tests__/happy.localeOnStartConversation.js: -------------------------------------------------------------------------------- 1 | /** @jest-environment ./__tests__/setup/jsdomEnvironmentWithProxy */ 2 | 3 | import 'dotenv/config'; 4 | 5 | import onErrorResumeNext from 'on-error-resume-next'; 6 | 7 | import { timeouts } from './constants.json'; 8 | import * as createDirectLine from './setup/createDirectLine'; 9 | import waitForBotToRespond from './setup/waitForBotToRespond'; 10 | 11 | // Skipping because the bot at dljstestbot.azurewebsites.net is not available. 12 | describe.skip('Happy path', () => { 13 | let unsubscribes; 14 | 15 | beforeEach(() => unsubscribes = []); 16 | afterEach(() => unsubscribes.forEach(fn => onErrorResumeNext(fn))); 17 | 18 | describe('should receive the welcome message from bot in English', () => { 19 | let directLine; 20 | 21 | describe('using REST', () => { 22 | beforeEach(() => jest.setTimeout(timeouts.rest)); 23 | 24 | test('without conversation start properties', async () => { 25 | directLine = await createDirectLine.forREST({ token: true }); 26 | }); 27 | 28 | test('without locale in conversation start properties', async () => { 29 | directLine = await createDirectLine.forREST({ token: true }, { conversationStartProperties: {} }); 30 | }); 31 | 32 | test('with locale "en-US" in conversation start properties', async () => { 33 | directLine = await createDirectLine.forREST({ token: true }, { conversationStartProperties: { locale: 'en-US' } }); 34 | }); 35 | }); 36 | 37 | describe('using Web Socket', () => { 38 | beforeEach(() => jest.setTimeout(timeouts.webSocket)); 39 | 40 | test('without conversation start properties', async () => { 41 | directLine = await createDirectLine.forWebSocket({ token: true }); 42 | }); 43 | 44 | test('without locale in conversation start properties', async () => { 45 | directLine = await createDirectLine.forWebSocket({ token: true }, { conversationStartProperties: {} }); 46 | }); 47 | 48 | test('with locale "en-US" in conversation start properties', async () => { 49 | directLine = await createDirectLine.forWebSocket({ token: true }, { conversationStartProperties: { locale: 'en-US' } }); 50 | }); 51 | }); 52 | 53 | afterEach(async () => { 54 | // If directLine object is undefined, that means the test is failing. 55 | if (!directLine) { return; } 56 | 57 | unsubscribes.push(directLine.end.bind(directLine)); 58 | 59 | await waitForBotToRespond(directLine, ({ text }) => text === 'Welcome'); 60 | }); 61 | }); 62 | 63 | describe('should receive the welcome message from bot in Chinese', () => { 64 | let directLine; 65 | 66 | describe('using REST', () => { 67 | beforeEach(() => jest.setTimeout(timeouts.rest)); 68 | 69 | test('with locale "zh-CN" in conversation start properties', async () => { 70 | directLine = await createDirectLine.forREST({ token: true }, { conversationStartProperties: { locale: 'zh-CN' } }); 71 | }); 72 | }); 73 | 74 | describe('using Web Socket', () => { 75 | beforeEach(() => jest.setTimeout(timeouts.webSocket)); 76 | 77 | test('with locale "zh-CN" in conversation start properties', async () => { 78 | directLine = await createDirectLine.forWebSocket({ token: true }, { conversationStartProperties: { locale: 'zh-CN' } }); 79 | }); 80 | }); 81 | 82 | afterEach(async () => { 83 | // If directLine object is undefined, that means the test is failing. 84 | if (!directLine) { return; } 85 | 86 | unsubscribes.push(directLine.end.bind(directLine)); 87 | 88 | await waitForBotToRespond(directLine, ({ text }) => text === '欢迎'); 89 | }); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /__tests__/happy.postActivity.js: -------------------------------------------------------------------------------- 1 | /** @jest-environment ./__tests__/setup/jsdomEnvironmentWithProxy */ 2 | 3 | import 'dotenv/config'; 4 | 5 | import onErrorResumeNext from 'on-error-resume-next'; 6 | 7 | import { timeouts } from './constants.json'; 8 | import * as createDirectLine from './setup/createDirectLine'; 9 | import postActivity from './setup/postActivity'; 10 | import waitForBotToEcho from './setup/waitForBotToEcho'; 11 | import waitForConnected from './setup/waitForConnected'; 12 | 13 | // Skipping because the bot at dljstestbot.azurewebsites.net is not available. 14 | describe.skip('Happy path', () => { 15 | let unsubscribes; 16 | 17 | beforeEach(() => unsubscribes = []); 18 | afterEach(() => unsubscribes.forEach(fn => onErrorResumeNext(fn))); 19 | 20 | describe('should connect, send message, and receive echo from bot', () => { 21 | let directLine; 22 | 23 | describe('using REST', () => { 24 | beforeEach(() => jest.setTimeout(timeouts.rest)); 25 | 26 | test('with secret', async () => { 27 | directLine = await createDirectLine.forREST({ token: false }); 28 | }); 29 | 30 | test('with token', async () => { 31 | directLine = await createDirectLine.forREST({ token: true }); 32 | }); 33 | }); 34 | 35 | test('using Streaming Extensions', async () => { 36 | jest.setTimeout(timeouts.webSocket); 37 | directLine = await createDirectLine.forStreamingExtensions(); 38 | }); 39 | 40 | describe('using Web Socket', () => { 41 | beforeEach(() => jest.setTimeout(timeouts.webSocket)); 42 | 43 | test('with secret', async () => { 44 | directLine = await createDirectLine.forWebSocket({ token: false }); 45 | }); 46 | 47 | test('with token', async () => { 48 | directLine = await createDirectLine.forWebSocket({ token: false }); 49 | }); 50 | }); 51 | 52 | afterEach(async () => { 53 | // If directLine object is undefined, that means the test is failing. 54 | if (!directLine) { return; } 55 | 56 | unsubscribes.push(directLine.end.bind(directLine)); 57 | unsubscribes.push(await waitForConnected(directLine)); 58 | 59 | await Promise.all([ 60 | postActivity(directLine, { text: 'Hello, World!', type: 'message' }), 61 | waitForBotToEcho(directLine, ({ text }) => text === 'Hello, World!') 62 | ]); 63 | }); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /__tests__/happy.receiveAttachmentStreams.js: -------------------------------------------------------------------------------- 1 | /** @jest-environment ./__tests__/setup/jsdomEnvironmentWithProxy */ 2 | 3 | import 'dotenv/config'; 4 | 5 | import onErrorResumeNext from 'on-error-resume-next'; 6 | 7 | import { timeouts } from './constants.json'; 8 | import * as createDirectLine from './setup/createDirectLine'; 9 | import fetchAsBase64 from './setup/fetchAsBase64'; 10 | import postActivity from './setup/postActivity'; 11 | import waitForBotToEcho from './setup/waitForBotToEcho'; 12 | import waitForConnected from './setup/waitForConnected'; 13 | import waitForBotToRespond from './setup/waitForBotToRespond.js'; 14 | 15 | // Skipping because the bot at dljstestbot.azurewebsites.net is not available. 16 | describe.skip('Happy path', () => { 17 | let unsubscribes; 18 | 19 | beforeEach(() => unsubscribes = []); 20 | afterEach(() => unsubscribes.forEach(fn => onErrorResumeNext(fn))); 21 | 22 | describe('receive attachments', () => { 23 | let directLine; 24 | 25 | test('using Streaming Extensions', async () => { 26 | jest.setTimeout(timeouts.webSocket); 27 | directLine = await createDirectLine.forStreamingExtensions(); 28 | }); 29 | 30 | afterEach(async () => { 31 | // If directLine object is undefined, that means the test is failing. 32 | if (!directLine) { return; } 33 | 34 | unsubscribes.push(directLine.end.bind(directLine)); 35 | unsubscribes.push(await waitForConnected(directLine)); 36 | 37 | let url1 = 'https://webchat-mockbot.azurewebsites.net/public/assets/surface1.jpg'; 38 | let url2 = 'https://webchat-mockbot.azurewebsites.net/public/assets/surface2.jpg'; 39 | 40 | const activityFromUser = { 41 | text: 'attach ' + url1 + ' ' + url2, 42 | type: 'message', 43 | channelData: { 44 | testType: "streaming" 45 | } 46 | }; 47 | 48 | await Promise.all([ 49 | postActivity(directLine, activityFromUser), 50 | waitForBotToRespond(directLine, async (activity) => { 51 | if (!activity.channelData){ 52 | return false; 53 | } 54 | let attachmentContents1 = await fetchAsBase64(url1); 55 | let attachmentContents2 = await fetchAsBase64(url2); 56 | const prefixLength = "data:text/plain;base64,".length; 57 | return (activity.attachments.length == 2 && 58 | attachmentContents1 == activity.attachments[0].contentUrl.substr(prefixLength) && 59 | attachmentContents2 == activity.attachments[1].contentUrl.substr(prefixLength)); 60 | }) 61 | ]); 62 | }); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /__tests__/happy.replaceActivityFromId.js: -------------------------------------------------------------------------------- 1 | /** @jest-environment ./__tests__/setup/jsdomEnvironmentWithProxy */ 2 | 3 | import 'dotenv/config'; 4 | 5 | import onErrorResumeNext from 'on-error-resume-next'; 6 | 7 | import { timeouts } from './constants.json'; 8 | import * as createDirectLine from './setup/createDirectLine'; 9 | import postActivity from './setup/postActivity'; 10 | import waitForBotToRespond from './setup/waitForBotToRespond'; 11 | 12 | describe('Happy path', () => { 13 | let unsubscribes; 14 | 15 | beforeEach(() => unsubscribes = []); 16 | afterEach(() => unsubscribes.forEach(fn => onErrorResumeNext(fn))); 17 | 18 | describe('should receive the welcome message from bot', () => { 19 | let directLine; 20 | 21 | describe('using REST', () => { 22 | beforeEach(() => jest.setTimeout(timeouts.rest)); 23 | 24 | test('with secret', async () => { 25 | directLine = await createDirectLine.forREST({ token: false }); 26 | }); 27 | }); 28 | 29 | describe('using Web Socket', () => { 30 | beforeEach(() => jest.setTimeout(timeouts.webSocket)); 31 | 32 | test('with secret', async () => { 33 | directLine = await createDirectLine.forWebSocket({ token: false }); 34 | }); 35 | }); 36 | 37 | afterEach(async () => { 38 | // If directLine object is undefined, that means the test is failing. 39 | if (!directLine) { return; } 40 | 41 | unsubscribes.push(directLine.end.bind(directLine)); 42 | 43 | directLine.setUserId('u_test'); 44 | 45 | await Promise.all([ 46 | postActivity(directLine, { text: 'Hello, World!', type: 'message' }), 47 | waitForBotToRespond(directLine, ({ from }) => from.id === 'u_test') 48 | ]); 49 | }); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /__tests__/happy.uploadAttachmentStreams.js: -------------------------------------------------------------------------------- 1 | /** @jest-environment ./__tests__/setup/jsdomEnvironmentWithProxy */ 2 | 3 | import 'dotenv/config'; 4 | 5 | import onErrorResumeNext from 'on-error-resume-next'; 6 | 7 | import { timeouts } from './constants.json'; 8 | import * as createDirectLine from './setup/createDirectLine'; 9 | import fetchAsBase64 from './setup/fetchAsBase64'; 10 | import postActivity from './setup/postActivity'; 11 | import waitForBotToEcho from './setup/waitForBotToEcho'; 12 | import waitForConnected from './setup/waitForConnected'; 13 | 14 | jest.setTimeout(10000); 15 | 16 | // Skipping because the bot at dljstestbot.azurewebsites.net is not available. 17 | describe.skip('Happy path', () => { 18 | let unsubscribes; 19 | 20 | beforeEach(() => unsubscribes = []); 21 | afterEach(() => unsubscribes.forEach(fn => onErrorResumeNext(fn))); 22 | 23 | describe('upload 2 attachments with text messages', () => { 24 | let directLine; 25 | 26 | test('using Streaming Extensions', async () => { 27 | jest.setTimeout(timeouts.webSocket); 28 | directLine = await createDirectLine.forStreamingExtensions(); 29 | }); 30 | 31 | afterEach(async () => { 32 | // If directLine object is undefined, that means the test is failing. 33 | if (!directLine) { return; } 34 | 35 | unsubscribes.push(directLine.end.bind(directLine)); 36 | unsubscribes.push(await waitForConnected(directLine)); 37 | 38 | const activityFromUser = { 39 | // DirectLine.postActivityWithAttachments support "contentUrl" only but not "content" 40 | attachments: [{ 41 | contentType: 'image/jpg', 42 | contentUrl: 'https://webchat-mockbot.azurewebsites.net/public/assets/surface1.jpg' 43 | }, { 44 | contentType: 'image/jpg', 45 | contentUrl: 'https://webchat-mockbot.azurewebsites.net/public/assets/surface2.jpg' 46 | }], 47 | text: 'Hello, World!', 48 | type: 'message', 49 | channelData: { 50 | testType: "streaming" 51 | } 52 | }; 53 | 54 | await Promise.all([ 55 | postActivity(directLine, activityFromUser), 56 | waitForBotToEcho(directLine, async ({ attachments, text }) => { 57 | if (text === 'Hello, World!' && attachments) { 58 | const [expectedContents, actualContents] = await Promise.all([ 59 | Promise.all([ 60 | fetchAsBase64(activityFromUser.attachments[0].contentUrl), 61 | fetchAsBase64(activityFromUser.attachments[1].contentUrl) 62 | ]), 63 | ]); 64 | 65 | 66 | let result = ( (expectedContents[0] === attachments[0].contentUrl && 67 | expectedContents[1] === attachments[1].contentUrl) || 68 | (expectedContents[1] === attachments[0].contentUrl && 69 | expectedContents[0] === attachments[1].contentUrl) ); 70 | 71 | if (!result) { 72 | console.warn(attachments[0].contentUrl); 73 | console.warn(attachments[1].contentUrl); 74 | } 75 | 76 | return result; 77 | } 78 | }) 79 | ]); 80 | }); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /__tests__/happy.uploadAttachments.js: -------------------------------------------------------------------------------- 1 | /** @jest-environment ./__tests__/setup/jsdomEnvironmentWithProxy */ 2 | 3 | import 'dotenv/config'; 4 | 5 | import onErrorResumeNext from 'on-error-resume-next'; 6 | 7 | import { timeouts } from './constants.json'; 8 | import * as createDirectLine from './setup/createDirectLine'; 9 | import fetchAsBase64 from './setup/fetchAsBase64'; 10 | import postActivity from './setup/postActivity'; 11 | import waitForBotToEcho from './setup/waitForBotToEcho'; 12 | import waitForConnected from './setup/waitForConnected'; 13 | 14 | jest.setTimeout(10000); 15 | 16 | // Skipping because the bot at dljstestbot.azurewebsites.net is not available. 17 | describe.skip('Happy path', () => { 18 | let unsubscribes; 19 | 20 | beforeEach(() => unsubscribes = []); 21 | afterEach(() => unsubscribes.forEach(fn => onErrorResumeNext(fn))); 22 | 23 | describe('upload 2 attachments with text messages', () => { 24 | let directLine; 25 | 26 | describe('using REST', () => { 27 | beforeEach(() => jest.setTimeout(timeouts.rest)); 28 | 29 | test('with secret', async () => { 30 | directLine = await createDirectLine.forREST({ token: false }); 31 | }); 32 | 33 | test('with token', async () => { 34 | directLine = await createDirectLine.forREST({ token: true }); 35 | }); 36 | }); 37 | 38 | describe('using Web Socket', () => { 39 | beforeEach(() => jest.setTimeout(timeouts.webSocket)); 40 | 41 | test('with secret', async () => { 42 | directLine = await createDirectLine.forWebSocket({ token: false }); 43 | }); 44 | 45 | test('with token', async () => { 46 | directLine = await createDirectLine.forWebSocket({ token: false }); 47 | }); 48 | }); 49 | 50 | afterEach(async () => { 51 | // If directLine object is undefined, that means the test is failing. 52 | if (!directLine) { return; } 53 | 54 | unsubscribes.push(directLine.end.bind(directLine)); 55 | unsubscribes.push(await waitForConnected(directLine)); 56 | 57 | const activityFromUser = { 58 | // DirectLine.postActivityWithAttachments support "contentUrl" only but not "content" 59 | attachments: [{ 60 | contentType: 'image/jpg', 61 | contentUrl: 'https://webchat-mockbot.azurewebsites.net/public/assets/surface1.jpg', 62 | thumbnailUrl: 'data:image/png;base64,===177KB.jpg' 63 | }, { 64 | contentType: 'image/png', 65 | contentUrl: 'https://webchat-mockbot.azurewebsites.net/public/assets/surface2.jpg', 66 | thumbnailUrl: 'data:image/png;base64,===100KB.jpb' 67 | }], 68 | text: 'Hello, World!', 69 | type: 'message' 70 | }; 71 | 72 | await Promise.all([ 73 | postActivity(directLine, activityFromUser), 74 | waitForBotToEcho(directLine, async ({ attachments, text }) => { 75 | if (text === 'Hello, World!') { 76 | // Bug #194 is causing trouble on the order of attachments sent. 77 | // https://github.com/microsoft/BotFramework-DirectLineJS/issues/194 78 | 79 | // Until the bug is fixed, we will not check the order. 80 | 81 | const [expectedContents, actualContents] = await Promise.all([ 82 | Promise.all([ 83 | fetchAsBase64(activityFromUser.attachments[0].contentUrl), 84 | fetchAsBase64(activityFromUser.attachments[1].contentUrl) 85 | ]), 86 | Promise.all([ 87 | fetchAsBase64(attachments[0].contentUrl), 88 | fetchAsBase64(attachments[1].contentUrl) 89 | ]) 90 | ]); 91 | 92 | const actualThumbnailUrls = attachments.map(({ thumbnailUrl }) => thumbnailUrl); 93 | 94 | return ( 95 | attachments[0] !== attachments[1] 96 | && actualContents.includes(expectedContents[0]) 97 | && actualContents.includes(expectedContents[1]) 98 | && actualThumbnailUrls.includes(activityFromUser.attachments[0].thumbnailUrl) 99 | && actualThumbnailUrls.includes(activityFromUser.attachments[1].thumbnailUrl) 100 | ); 101 | 102 | // Use the commented code below after bug #194 is fixed. 103 | // https://github.com/microsoft/BotFramework-DirectLineJS/issues/194 104 | 105 | // return ( 106 | // await fetchAsBase64(attachments[0].contentUrl) === await fetchAsBase64(activityFromUser.attachments[0].contentUrl) 107 | // && await fetchAsBase64(attachments[1].contentUrl) === await fetchAsBase64(activityFromUser.attachments[1].contentUrl) 108 | // && attachments[0].thumbnailUrl === activityFromUser.attachments[0].thumbnailUrl 109 | // && attachments[1].thumbnailUrl === activityFromUser.attachments[1].thumbnailUrl 110 | // ); 111 | } 112 | }) 113 | ]); 114 | }); 115 | }); 116 | }); 117 | -------------------------------------------------------------------------------- /__tests__/happy.userIdOnStartConversation.js: -------------------------------------------------------------------------------- 1 | /** @jest-environment ./__tests__/setup/jsdomEnvironmentWithProxy */ 2 | 3 | import 'dotenv/config'; 4 | 5 | import onErrorResumeNext from 'on-error-resume-next'; 6 | 7 | import { timeouts } from './constants.json'; 8 | import * as createDirectLine from './setup/createDirectLine'; 9 | import waitForBotToRespond from './setup/waitForBotToRespond'; 10 | 11 | describe('Happy path', () => { 12 | let unsubscribes; 13 | 14 | beforeEach(() => unsubscribes = []); 15 | afterEach(() => unsubscribes.forEach(fn => onErrorResumeNext(fn))); 16 | 17 | describe('should receive the welcome message from bot', () => { 18 | let directLine; 19 | 20 | describe('using REST', () => { 21 | beforeEach(() => jest.setTimeout(timeouts.rest)); 22 | 23 | test('with secret', async () => { 24 | directLine = await createDirectLine.forREST({ token: false }); 25 | }); 26 | }); 27 | 28 | describe('using Web Socket', () => { 29 | beforeEach(() => jest.setTimeout(timeouts.webSocket)); 30 | 31 | test('with secret', async () => { 32 | directLine = await createDirectLine.forWebSocket({ token: false }); 33 | }); 34 | }); 35 | 36 | afterEach(async () => { 37 | // If directLine object is undefined, that means the test is failing. 38 | if (!directLine) { return; } 39 | 40 | unsubscribes.push(directLine.end.bind(directLine)); 41 | 42 | directLine.setUserId('u_test'); 43 | 44 | await waitForBotToRespond(directLine, ({ text }) => text === 'Welcome'); 45 | }); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /__tests__/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Direct Line JS 5 | 6 | 7 | 44 | 45 | 46 |
47 | 53 |
54 |
    55 | 133 | 134 | 135 | -------------------------------------------------------------------------------- /__tests__/setup.ts: -------------------------------------------------------------------------------- 1 | /** @jest-environment ./__tests__/setup/jsdomEnvironmentWithProxy */ 2 | 3 | /// 4 | 5 | import createServer from './setup/createServer'; 6 | import fetch from 'node-fetch'; 7 | 8 | test('setup correctly', () => {}); 9 | 10 | test('createServer setup correctly', async () => { 11 | const { dispose, port } = await createServer({ 12 | playbacks: [{ 13 | req: { url: '/health.txt' }, 14 | res: { body: 'OK' } 15 | }] 16 | }); 17 | 18 | try { 19 | const res = await fetch(`http://localhost:${ port }/health.txt`, {}); 20 | 21 | expect(res).toHaveProperty('ok', true); 22 | } finally { 23 | dispose(); 24 | } 25 | }); 26 | 27 | test('test environment has Web Cryptography API', () => { 28 | expect(typeof global.crypto.getRandomValues).toBe('function'); 29 | }); 30 | -------------------------------------------------------------------------------- /__tests__/setup/createDirectLine.js: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | 3 | import { userId as DEFAULT_USER_ID } from '../constants.json'; 4 | import { DirectLine, DirectLineStreaming } from '../../dist/directline'; 5 | 6 | const { 7 | DIRECT_LINE_SECRET, 8 | STREAMING_EXTENSIONS_DOMAIN = 'https://dljstestbot.azurewebsites.net/.bot/v3/directline' 9 | } = process.env; 10 | 11 | const DEFAULT_DOMAIN = 'https://directline.botframework.com/v3/directline'; 12 | 13 | async function fetchDirectLineToken() { 14 | const res = await fetch('https://dljstestbot.azurewebsites.net/token/directline'); 15 | 16 | if (res.ok) { 17 | return await res.json(); 18 | } else { 19 | throw new Error(`Server returned ${ res.status } while fetching Direct Line token`); 20 | } 21 | } 22 | 23 | async function fetchDirectLineStreamingExtensionsToken() { 24 | const res = await fetch(`https://dljstestbot.azurewebsites.net/token/directlinease`); 25 | 26 | if (res.ok) { 27 | return await res.json(); 28 | } else { 29 | throw new Error(`Server returned ${ res.status } while fetching Direct Line token`); 30 | } 31 | } 32 | 33 | async function generateDirectLineToken(domain = DEFAULT_DOMAIN) { 34 | let res; 35 | 36 | res = await fetch(`${ domain }/tokens/generate`, { 37 | body: JSON.stringify({ User: { Id: DEFAULT_USER_ID } }), 38 | headers: { 39 | authorization: `Bearer ${ DIRECT_LINE_SECRET }`, 40 | 'Content-Type': 'application/json' 41 | }, 42 | method: 'POST' 43 | }); 44 | 45 | if (res.status === 200) { 46 | const json = await res.json(); 47 | 48 | if ('error' in json) { 49 | throw new Error(`Direct Line service responded with ${ JSON.stringify(json.error) } while generating a new token`); 50 | } else { 51 | return json; 52 | } 53 | } else { 54 | throw new Error(`Direct Line service returned ${ res.status } while generating a new token`); 55 | } 56 | } 57 | 58 | export async function forREST({ token } = {}, mergeOptions = {}) { 59 | let options = { webSocket: false }; 60 | 61 | if (token && DIRECT_LINE_SECRET) { 62 | options = { ...options, token: (await generateDirectLineToken()).token }; 63 | } else if (token) { 64 | // Probably via PR validation on Travis, or run by a contributing developer. 65 | // We still want to let the developer to test majority of stuff without deploying their own bot server. 66 | options = { ...options, token: (await fetchDirectLineToken()).token }; 67 | } else if (DIRECT_LINE_SECRET) { 68 | options = { ...options, secret: DIRECT_LINE_SECRET }; 69 | } else { 70 | return console.warn('Tests using secret are skipped because DIRECT_LINE_SECRET environment variable is not defined.'); 71 | } 72 | 73 | return new DirectLine({ ...options, ...mergeOptions }); 74 | } 75 | 76 | export async function forStreamingExtensions(mergeOptions = {}) { 77 | const { conversationId, token } = DIRECT_LINE_SECRET ? 78 | await generateDirectLineToken(STREAMING_EXTENSIONS_DOMAIN) 79 | : 80 | await fetchDirectLineStreamingExtensionsToken(); 81 | 82 | return new DirectLineStreaming({ 83 | conversationId, 84 | domain: STREAMING_EXTENSIONS_DOMAIN, 85 | token, 86 | webSocket: true, 87 | ...mergeOptions 88 | }); 89 | } 90 | 91 | export async function forWebSocket({ token } = {}, mergeOptions = {}) { 92 | return await forREST( 93 | { token }, 94 | { 95 | webSocket: true, 96 | ...mergeOptions 97 | } 98 | ); 99 | } 100 | -------------------------------------------------------------------------------- /__tests__/setup/createDirectLineForwarder.js: -------------------------------------------------------------------------------- 1 | import { createProxyServer } from 'http-proxy'; 2 | import { createServer } from 'http'; 3 | import { promisify } from 'util'; 4 | 5 | export default async function createDirectLineForwarder( 6 | port, 7 | handler, 8 | target = 'https://directline.botframework.com/' 9 | ) { 10 | // We need a reverse proxy (a.k.a. forwarder) to control the network traffic. 11 | // This is because we need to modify the HTTP header by changing its host header (directline.botframework.com do not like "Host: localhost"). 12 | 13 | const proxy = createProxyServer({ 14 | changeOrigin: true, 15 | rejectUnauthorized: false, 16 | target 17 | }); 18 | 19 | proxy.on('proxyReq', (proxyRes, req, res, options) => { 20 | // JSDOM requires all HTTP response, including those already pre-flighted, to have "Access-Control-Allow-Origin". 21 | // https://github.com/jsdom/jsdom/issues/2024 22 | res.setHeader('Access-Control-Allow-Origin', req.headers.origin || '*'); 23 | }); 24 | 25 | const proxyServer = createServer((req, res) => { 26 | handler(req, res, () => proxy.web(req, res)); 27 | }); 28 | 29 | await promisify(proxyServer.listen.bind(proxyServer))(port); 30 | 31 | return { 32 | domain: `http://localhost:${port}/v3/directline`, 33 | unsubscribe: promisify(proxyServer.close.bind(proxyServer)) 34 | }; 35 | } 36 | -------------------------------------------------------------------------------- /__tests__/setup/createPromiseQueue.js: -------------------------------------------------------------------------------- 1 | export default function createPromiseQueue() { 2 | const resolves = []; 3 | const stack = []; 4 | const trigger = () => resolves.length && stack.length && resolves.shift()(stack.shift()); 5 | 6 | return { 7 | push(value) { 8 | stack.push(value); 9 | trigger(); 10 | }, 11 | shift() { 12 | const promise = new Promise(resolve => resolves.push(resolve)); 13 | 14 | trigger(); 15 | 16 | return promise; 17 | } 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /__tests__/setup/createServer.test.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | 3 | import createServer from './createServer'; 4 | import hasResolved from 'has-resolved'; 5 | 6 | test('GET /once.txt should return 200 OK', async () => { 7 | const { dispose, port, promises } = await createServer({ 8 | playbacks: [{ 9 | req: { method: 'GET', url: '/once.txt' }, 10 | res: { body: 'OK' } 11 | }] 12 | }); 13 | 14 | try { 15 | const res1 = await fetch(`http://localhost:${ port }/once.txt`, undefined); 16 | 17 | expect(res1).toHaveProperty('ok', true); 18 | expect(await hasResolved(promises[0])).toBeTruthy(); 19 | 20 | const res2 = await fetch(`http://localhost:${ port }/once.txt`, undefined); 21 | 22 | expect(res2).toHaveProperty('status', 404); 23 | } finally { 24 | dispose(); 25 | } 26 | }); 27 | 28 | test('OPTIONS /once.txt should keep return 200 OK until GET /once.txt', async () => { 29 | const { dispose, port, promises } = await createServer({ 30 | playbacks: [{ 31 | req: { method: 'GET', url: '/once.txt' }, 32 | res: { body: 'OK' } 33 | }] 34 | }); 35 | 36 | try { 37 | const res1 = await fetch(`http://localhost:${ port }/once.txt`, { method: 'OPTIONS' }); 38 | 39 | expect(res1).toHaveProperty('ok', true); 40 | expect(await hasResolved(promises[0])).toBeFalsy(); 41 | 42 | const res2 = await fetch(`http://localhost:${ port }/once.txt`, { method: 'OPTIONS' }); 43 | 44 | expect(res2).toHaveProperty('ok', true); 45 | expect(await hasResolved(promises[0])).toBeFalsy(); 46 | 47 | const res3 = await fetch(`http://localhost:${ port }/once.txt`, undefined); 48 | 49 | expect(res3).toHaveProperty('ok', true); 50 | expect(await hasResolved(promises[0])).toBeTruthy(); 51 | 52 | const res4 = await fetch(`http://localhost:${ port }/once.txt`, { method: 'OPTIONS' }); 53 | 54 | expect(res4).toHaveProperty('status', 404); 55 | expect(await hasResolved(promises[0])).toBeTruthy(); 56 | } finally { 57 | dispose(); 58 | } 59 | }); 60 | 61 | test('GET should succeed with a strict ordered sequence', async () => { 62 | const { dispose, port, promises } = await createServer({ 63 | playbacks: [{ 64 | req: { method: 'GET', url: '/1.txt' }, 65 | res: { body: '1' } 66 | }, { 67 | req: { method: 'GET', url: '/2.txt' }, 68 | res: { body: '2' } 69 | }] 70 | }); 71 | 72 | try { 73 | const res1 = await fetch(`http://localhost:${ port }/1.txt`, undefined); 74 | 75 | expect(res1).toHaveProperty('ok', true); 76 | expect(await res1.text()).toBe('1'); 77 | expect(await hasResolved(promises[0])).toBeTruthy(); 78 | expect(await hasResolved(promises[1])).toBeFalsy(); 79 | 80 | const res2 = await fetch(`http://localhost:${ port }/2.txt`, undefined); 81 | 82 | expect(res2).toHaveProperty('ok', true); 83 | expect(await res2.text()).toBe('2'); 84 | expect(await hasResolved(promises[0])).toBeTruthy(); 85 | expect(await hasResolved(promises[1])).toBeTruthy(); 86 | 87 | const res3 = await fetch(`http://localhost:${ port }/1.txt`, undefined); 88 | 89 | expect(res3).toHaveProperty('status', 404); 90 | } finally { 91 | dispose(); 92 | } 93 | }); 94 | 95 | test('GET should fail if out-of-order', async () => { 96 | const { dispose, port, promises } = await createServer({ 97 | playbacks: [{ 98 | req: { method: 'GET', url: '/1.txt' }, 99 | res: { body: '1' } 100 | }, { 101 | req: { method: 'GET', url: '/2.txt' }, 102 | res: { body: '2' } 103 | }] 104 | }); 105 | 106 | try { 107 | const res1 = await fetch(`http://localhost:${ port }/2.txt`, undefined); 108 | 109 | expect(res1).toHaveProperty('status', 404); 110 | expect(await hasResolved(promises[0])).toBeFalsy(); 111 | expect(await hasResolved(promises[1])).toBeFalsy(); 112 | 113 | const res2 = await fetch(`http://localhost:${ port }/1.txt`, undefined); 114 | 115 | expect(res2).toHaveProperty('ok', true); 116 | expect(await hasResolved(promises[0])).toBeTruthy(); 117 | expect(await hasResolved(promises[1])).toBeFalsy(); 118 | 119 | const res3 = await fetch(`http://localhost:${ port }/2.txt`, undefined); 120 | 121 | expect(res3).toHaveProperty('ok', true); 122 | expect(await hasResolved(promises[0])).toBeTruthy(); 123 | expect(await hasResolved(promises[1])).toBeTruthy(); 124 | 125 | const res4 = await fetch(`http://localhost:${ port }/2.txt`, undefined); 126 | 127 | expect(res4).toHaveProperty('status', 404); 128 | } finally { 129 | dispose(); 130 | } 131 | }); 132 | 133 | test('GET unordered requests', async () => { 134 | const { dispose, port, promises } = await createServer({ 135 | playbacks: [ 136 | [ 137 | { 138 | req: { method: 'GET', url: '/1a.txt' }, 139 | res: { body: '1a' } 140 | }, { 141 | req: { method: 'GET', url: '/1b.txt' }, 142 | res: { body: '1b' } 143 | } 144 | ], 145 | { 146 | req: { method: 'GET', url: '/2.txt' }, 147 | res: { body: '2' } 148 | } 149 | ] 150 | }); 151 | 152 | try { 153 | // 404: We must get either 1a or 1b first 154 | const res1 = await fetch(`http://localhost:${ port }/2.txt`, undefined); 155 | 156 | expect(res1).toHaveProperty('status', 404); 157 | expect(await hasResolved(promises[1])).toBeFalsy(); 158 | 159 | // 200: We get either 1a or 1b 160 | const res2 = await fetch(`http://localhost:${ port }/1b.txt`, undefined); 161 | 162 | expect(res2).toHaveProperty('ok', true); 163 | expect(await hasResolved(promises[0][0])).toBeFalsy(); 164 | expect(await hasResolved(promises[0][1])).toBeTruthy(); 165 | 166 | // 404: We must get 1a first 167 | const res3 = await fetch(`http://localhost:${ port }/2.txt`, undefined); 168 | 169 | expect(res3).toHaveProperty('status', 404); 170 | 171 | // 200: We got 1a 172 | const res4 = await fetch(`http://localhost:${ port }/1a.txt`, undefined); 173 | 174 | expect(res4).toHaveProperty('ok', true); 175 | expect(await hasResolved(promises[0][0])).toBeTruthy(); 176 | expect(await hasResolved(promises[0][1])).toBeTruthy(); 177 | 178 | // 200: We got 2 179 | const res5 = await fetch(`http://localhost:${ port }/2.txt`, undefined); 180 | 181 | expect(res5).toHaveProperty('ok', true); 182 | expect(await hasResolved(promises[1])).toBeTruthy(); 183 | 184 | // 404: Playbacks is finished 185 | const res6 = await fetch(`http://localhost:${ port }/2.txt`, undefined); 186 | 187 | expect(res6).toHaveProperty('status', 404); 188 | } finally { 189 | dispose(); 190 | } 191 | }); 192 | -------------------------------------------------------------------------------- /__tests__/setup/createServer.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { createServer } from 'restify'; 4 | import createDeferred from 'p-defer'; 5 | import getPort from 'get-port'; 6 | 7 | export type PlaybackWithDeferred = { 8 | deferred: createDeferred.DeferredPromise<{}>; 9 | } & Playback; 10 | 11 | export type Playback = { 12 | req: { 13 | method?: string; 14 | url?: string; 15 | }; 16 | res: { 17 | body?: any; 18 | code?: number; 19 | headers?: any; 20 | }; 21 | }; 22 | 23 | export type CreateServerOptions = { 24 | playbacks: (Playback | Playback[])[]; 25 | }; 26 | 27 | export type CreateServerResult = { 28 | dispose: () => Promise; 29 | port: number; 30 | promises: (Promise<{}> | Promise<{}>[])[]; 31 | }; 32 | 33 | export default async function (options: CreateServerOptions): Promise { 34 | const port = await getPort({ port: 5000 }); 35 | const server = createServer(); 36 | 37 | const orderedPlaybacks: PlaybackWithDeferred[][] = (options.playbacks || []).map(unorderedPlaybacks => { 38 | if (Array.isArray(unorderedPlaybacks)) { 39 | return unorderedPlaybacks.map(playback => ({ 40 | ...playback, 41 | deferred: createDeferred() 42 | })); 43 | } else { 44 | return [ 45 | { 46 | ...unorderedPlaybacks, 47 | deferred: createDeferred() 48 | } 49 | ]; 50 | } 51 | }); 52 | 53 | server.pre((req, res, next) => { 54 | const firstPlayback = orderedPlaybacks[0]; 55 | 56 | if (!firstPlayback) { 57 | return next(); 58 | } 59 | 60 | const unorderedPlaybacks = Array.isArray(firstPlayback) ? firstPlayback : [firstPlayback]; 61 | let handled; 62 | 63 | unorderedPlaybacks.forEach(({ deferred, req: preq = {}, res: pres = {} }, index) => { 64 | if (req.url === (preq.url || '/')) { 65 | if (req.method === 'OPTIONS') { 66 | res.send(200, '', { 67 | 'Access-Control-Allow-Origin': req.header('Origin') || '*', 68 | 'Access-Control-Allow-Methods': req.header('Access-Control-Request-Method') || 'GET', 69 | 'Access-Control-Allow-Headers': req.header('Access-Control-Request-Headers') || '', 70 | 'Content-Type': 'text/html; charset=utf-8' 71 | }); 72 | 73 | handled = true; 74 | } else if (req.method === (preq.method || 'GET')) { 75 | const headers: any = {}; 76 | 77 | if (typeof pres.body === 'string') { 78 | headers['Content-Type'] = 'text/plain'; 79 | } 80 | 81 | res.send(pres.code || 200, pres.body, { 82 | // JSDOM requires all HTTP response, including those already pre-flighted, to have "Access-Control-Allow-Origin". 83 | // https://github.com/jsdom/jsdom/issues/2024 84 | 'Access-Control-Allow-Origin': req.header('Origin') || '*', 85 | ...headers, 86 | ...pres.headers 87 | }); 88 | 89 | handled = true; 90 | deferred.resolve(); 91 | unorderedPlaybacks.splice(index, 1); 92 | 93 | if (!unorderedPlaybacks.length) { 94 | orderedPlaybacks.shift(); 95 | } 96 | } 97 | 98 | return; 99 | } 100 | }); 101 | 102 | if (!handled) { 103 | return next(); 104 | } 105 | }); 106 | 107 | server.listen(port); 108 | 109 | return { 110 | dispose: () => { 111 | return new Promise(resolve => server.close(resolve)); 112 | }, 113 | port, 114 | promises: options.playbacks.map((unorderedPlayback: Playback | Playback[], index) => { 115 | if (Array.isArray(unorderedPlayback)) { 116 | return (orderedPlaybacks[index] as PlaybackWithDeferred[]).map(({ deferred: { promise } }) => promise); 117 | } else { 118 | return orderedPlaybacks[index][0].deferred.promise; 119 | } 120 | }) 121 | }; 122 | } 123 | -------------------------------------------------------------------------------- /__tests__/setup/createUserId.js: -------------------------------------------------------------------------------- 1 | export default function createUserId() { 2 | return `dl_${ Math.random().toString(36).substr(2, 5) }`; 3 | } 4 | -------------------------------------------------------------------------------- /__tests__/setup/fetchAsBase64.js: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | 3 | export default async function fetchAsBase64(url) { 4 | const res = await fetch(url); 5 | 6 | if (res.ok) { 7 | const buffer = await res.buffer(); 8 | 9 | return buffer.toString('base64'); 10 | } else { 11 | throw new Error(`Server returned ${ res.status } while fetching as buffer`); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /__tests__/setup/get-port.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'get-port' { 2 | const getPort: (options?: { port?: number | ReadonlyArray, host?: string }) => PromiseLike; 3 | 4 | export default getPort; 5 | } 6 | -------------------------------------------------------------------------------- /__tests__/setup/getEchoActivity.js: -------------------------------------------------------------------------------- 1 | export default function getEchoActivity(activity) { 2 | const { 3 | channelData: { 4 | originalActivity 5 | } = {} 6 | } = activity; 7 | 8 | return originalActivity; 9 | } 10 | -------------------------------------------------------------------------------- /__tests__/setup/has-resolved.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'has-resolved' { 2 | const hasResolved: (promise: Promise<{}> | Promise<{}>[]) => Promise; 3 | 4 | export default hasResolved; 5 | } 6 | -------------------------------------------------------------------------------- /__tests__/setup/jsdomEnvironmentWithProxy.js: -------------------------------------------------------------------------------- 1 | require('global-agent/bootstrap'); 2 | 3 | // To use proxy, SET GLOBAL_AGENT_HTTP_PROXY=http://localhost:8888 4 | 5 | const JSDOMEnvironment = require('jest-environment-jsdom').TestEnvironment; 6 | 7 | class JSDOMEnvironmentWithProxy extends JSDOMEnvironment { 8 | setup() { 9 | if (process.env.GLOBAL_AGENT_HTTP_PROXY) { 10 | const { ResourceLoader } = require('jsdom'); 11 | const resources = new ResourceLoader({ strictSSL: false }); 12 | 13 | // HACK: We cannot set ResourceLoader thru testEnvironmentOptions.resources. 14 | // This is because the ResourceLoader instance constructor is of "slightly" different type when on runtime (probably Jest magic). 15 | // Thus, when we set it thru testEnvironmentOptions.resources, it will fail on "--watch" but succeed when running without watch. 16 | this.global._resourceLoader = resources; 17 | } 18 | 19 | return super.setup(); 20 | } 21 | } 22 | 23 | module.exports = JSDOMEnvironmentWithProxy; 24 | -------------------------------------------------------------------------------- /__tests__/setup/observableToPromise.js: -------------------------------------------------------------------------------- 1 | import createPromiseQueue from './createPromiseQueue'; 2 | 3 | export default function observableToPromise(observable) { 4 | let queue = createPromiseQueue(); 5 | 6 | const subscription = observable.subscribe({ 7 | complete() { 8 | queue.push({ complete: {} }); 9 | subscription.unsubscribe(); 10 | }, 11 | error(error) { 12 | queue.push({ error }); 13 | subscription.unsubscribe(); 14 | }, 15 | next(next) { 16 | queue.push({ next }); 17 | } 18 | }); 19 | 20 | return { 21 | shift: queue.shift.bind(queue), 22 | unsubscribe: subscription.unsubscribe.bind(subscription) 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /__tests__/setup/postActivity.js: -------------------------------------------------------------------------------- 1 | import updateIn from 'simple-update-in'; 2 | 3 | import waitForBotToRespond from './waitForBotToRespond'; 4 | import waitForObservable from './waitForObservable'; 5 | 6 | import { userId as DEFAULT_USER_ID } from '../constants.json'; 7 | 8 | export default async function postActivity(directLine, activity) { 9 | // We need to use channelData.clientActivityId because postActivity could come later than the activity$ observable. 10 | // Thus, when we receive the activity ID for the "just posted" activity, it might be already too late. 11 | 12 | const targetClientActivityId = Math.random().toString(36).substr(2); 13 | 14 | activity = updateIn(activity, ['from', 'id'], userId => userId || DEFAULT_USER_ID); 15 | activity = updateIn(activity, ['channelData', 'clientActivityId'], () => targetClientActivityId); 16 | 17 | const [activityId] = await Promise.all([ 18 | waitForObservable(directLine.postActivity(activity), () => true), 19 | waitForBotToRespond(directLine, ({ channelData: { clientActivityId } = {} }) => clientActivityId === targetClientActivityId) 20 | ]); 21 | 22 | return activityId; 23 | } 24 | -------------------------------------------------------------------------------- /__tests__/setup/sleep.js: -------------------------------------------------------------------------------- 1 | export default function sleep(ms = 1000) { 2 | return new Promise(resolve => setTimeout(resolve, ms)); 3 | } 4 | -------------------------------------------------------------------------------- /__tests__/setup/waitForBotToEcho.js: -------------------------------------------------------------------------------- 1 | import getEchoActivity from './getEchoActivity'; 2 | import waitForBotToRespond from './waitForBotToRespond'; 3 | 4 | export default function waitForBotToEcho(directLine, predicate) { 5 | return waitForBotToRespond(directLine, async activity => { 6 | const echoActivity = getEchoActivity(activity); 7 | 8 | return echoActivity && await predicate(echoActivity); 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /__tests__/setup/waitForBotToRespond.js: -------------------------------------------------------------------------------- 1 | import waitForObservable from './waitForObservable'; 2 | 3 | export default async function waitForBotToRespond(directLine, predicate) { 4 | return await waitForObservable(directLine.activity$, activity => predicate(activity)); 5 | } 6 | -------------------------------------------------------------------------------- /__tests__/setup/waitForConnected.js: -------------------------------------------------------------------------------- 1 | import { ConnectionStatus } from '../../src/directLine'; 2 | 3 | import waitForObservable from './waitForObservable'; 4 | 5 | export default async function waitForConnected(directLine) { 6 | const subscription = directLine.activity$.subscribe(); 7 | 8 | await waitForObservable(directLine.connectionStatus$, ConnectionStatus.Online); 9 | 10 | return subscription.unsubscribe.bind(subscription); 11 | } 12 | -------------------------------------------------------------------------------- /__tests__/setup/waitForObservable.js: -------------------------------------------------------------------------------- 1 | import observableToPromise from './observableToPromise'; 2 | 3 | export default async function waitForObservable(observable, target) { 4 | const { shift, unsubscribe } = observableToPromise(observable); 5 | 6 | try { 7 | for (;;) { 8 | const { complete, error, next } = await shift(); 9 | 10 | if (complete) { 11 | return; 12 | } else if (error) { 13 | throw error; 14 | } else if (typeof target === 'function' ? await target(next) : Object.is(next, target)) { 15 | return next; 16 | } 17 | } 18 | } finally { 19 | unsubscribe(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /__tests__/unhappy.brokenWebSocket.js: -------------------------------------------------------------------------------- 1 | /** @jest-environment ./__tests__/setup/jsdomEnvironmentWithProxy */ 2 | 3 | import 'dotenv/config'; 4 | import 'global-agent/bootstrap'; 5 | 6 | import { EventTarget, getEventAttributeValue, setEventAttributeValue } from 'event-target-shim'; 7 | import nock from 'nock'; 8 | import onErrorResumeNext from 'on-error-resume-next'; 9 | 10 | import { DirectLine } from '../src/directLine'; 11 | 12 | function corsReply(nockRequest) { 13 | nockRequest.reply(function () { 14 | const { headers } = this.req; 15 | 16 | return [ 17 | 200, 18 | null, 19 | { 20 | 'Access-Control-Allow-Headers': headers['access-control-request-headers'], 21 | 'Access-Control-Allow-Methods': headers['access-control-request-method'], 22 | 'Access-Control-Allow-Origin': headers.origin 23 | } 24 | ]; 25 | }); 26 | } 27 | 28 | describe('Unhappy path', () => { 29 | let unsubscribes; 30 | 31 | beforeEach(() => (unsubscribes = [])); 32 | afterEach(() => unsubscribes.forEach(fn => onErrorResumeNext(fn))); 33 | 34 | describe('broken Web Socket', () => { 35 | let numErrors; 36 | let numReconnections; 37 | 38 | beforeEach(async () => { 39 | numErrors = 0; 40 | numReconnections = 0; 41 | 42 | nock('https://directline.botframework.com') 43 | .persist() 44 | .post(uri => uri.startsWith('/v3/directline/conversations')) 45 | .reply( 46 | 200, 47 | JSON.stringify({ 48 | conversationId: '123', 49 | token: '456', 50 | streamUrl: 'wss://not-exist-domain' 51 | }) 52 | ) 53 | .get(uri => uri.startsWith('/v3/directline/conversations')) 54 | .reply( 55 | 200, 56 | JSON.stringify({ 57 | conversationId: '123', 58 | token: '456', 59 | streamUrl: 'wss://not-exist-domain' 60 | }) 61 | ); 62 | 63 | corsReply( 64 | nock('https://directline.botframework.com') 65 | .persist() 66 | .options(uri => uri.startsWith('/v3/directline/conversations')) 67 | ); 68 | 69 | window.WebSocket = class extends EventTarget { 70 | constructor() { 71 | super(); 72 | 73 | numReconnections++; 74 | 75 | setTimeout(() => { 76 | numErrors++; 77 | 78 | this.dispatchEvent(new ErrorEvent('error', { error: new Error('artificial') })); 79 | this.dispatchEvent(new CustomEvent('close')); 80 | }, 10); 81 | } 82 | 83 | get onclose() { 84 | return getEventAttributeValue(this, 'close'); 85 | } 86 | 87 | set onclose(value) { 88 | setEventAttributeValue(this, 'close', value); 89 | } 90 | 91 | get onerror() { 92 | return getEventAttributeValue(this, 'error'); 93 | } 94 | 95 | set onerror(value) { 96 | setEventAttributeValue(this, 'error', value); 97 | } 98 | }; 99 | }); 100 | 101 | afterEach(() => { 102 | nock.cleanAll(); 103 | }); 104 | 105 | test('should reconnect only once for every error', async () => { 106 | const directLine = new DirectLine({ 107 | token: '123', 108 | webSocket: true 109 | }); 110 | 111 | // Remove retry delay 112 | directLine.getRetryDelay = () => 0; 113 | 114 | unsubscribes.push(() => directLine.end()); 115 | 116 | await new Promise(resolve => { 117 | const subscription = directLine.activity$.subscribe(() => {}); 118 | 119 | setTimeout(() => { 120 | subscription.unsubscribe(); 121 | resolve(); 122 | }, 2000); 123 | }); 124 | 125 | // Because we abruptly stopped reconnection after 2 seconds, there is a 126 | // 10ms window that the number of reconnections is 1 more than number of errors. 127 | expect(Math.abs(numReconnections - numErrors)).toBeLessThanOrEqual(1); 128 | 129 | // As we loop reconnections for 2000 ms, and we inject errors every 10 ms. 130 | // We should only see at most 200 errors and reconnections. 131 | expect(numReconnections).toBeLessThanOrEqual(200); 132 | }); 133 | }); 134 | }); 135 | -------------------------------------------------------------------------------- /__tests__/unhappy.invalidLocaleOnStartConversation.js: -------------------------------------------------------------------------------- 1 | /** @jest-environment ./__tests__/setup/jsdomEnvironmentWithProxy */ 2 | 3 | import 'dotenv/config'; 4 | 5 | import onErrorResumeNext from 'on-error-resume-next'; 6 | 7 | import { timeouts } from './constants.json'; 8 | import * as createDirectLine from './setup/createDirectLine'; 9 | import waitForBotToRespond from './setup/waitForBotToRespond'; 10 | 11 | // Skipping because the bot at dljstestbot.azurewebsites.net is not available. 12 | describe.skip('Unhappy path', () => { 13 | let unsubscribes; 14 | 15 | beforeEach(() => unsubscribes = []); 16 | afterEach(() => unsubscribes.forEach(fn => onErrorResumeNext(fn))); 17 | 18 | describe('should receive the welcome message from bot in English', () => { 19 | let directLine; 20 | 21 | describe('using REST', () => { 22 | beforeEach(() => jest.setTimeout(timeouts.rest)); 23 | 24 | test('with invalid locale in conversation start properties', async () => { 25 | directLine = await createDirectLine.forREST({ token: true }, { conversationStartProperties: { locale: { x: 'test' } } }); 26 | }); 27 | }); 28 | 29 | describe('using Web Socket', () => { 30 | beforeEach(() => jest.setTimeout(timeouts.webSocket)); 31 | 32 | test('with invalid locale in conversation start properties', async () => { 33 | directLine = await createDirectLine.forWebSocket({ token: true }, { conversationStartProperties: { locale: { x: 'test' } } }); 34 | }); 35 | }); 36 | 37 | afterEach(async () => { 38 | // If directLine object is undefined, that means the test is failing. 39 | if (!directLine) { return; } 40 | 41 | unsubscribes.push(directLine.end.bind(directLine)); 42 | 43 | await waitForBotToRespond(directLine, ({ text }) => text === 'Welcome'); 44 | }); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /__tests__/unhappy.postActivityFatalAfterConnect.js: -------------------------------------------------------------------------------- 1 | /** @jest-environment ./__tests__/setup/jsdomEnvironmentWithProxy */ 2 | 3 | import 'dotenv/config'; 4 | 5 | import getPort from 'get-port'; 6 | import onErrorResumeNext from 'on-error-resume-next'; 7 | 8 | import { timeouts } from './constants.json'; 9 | import * as createDirectLine from './setup/createDirectLine'; 10 | import createDirectLineForwarder from './setup/createDirectLineForwarder'; 11 | import postActivity from './setup/postActivity'; 12 | import waitForBotToEcho from './setup/waitForBotToEcho'; 13 | import waitForConnected from './setup/waitForConnected'; 14 | 15 | // Skipping because the bot at dljstestbot.azurewebsites.net is not available. 16 | describe.skip('Unhappy path', () => { 17 | let unsubscribes; 18 | 19 | beforeEach(() => (unsubscribes = [])); 20 | afterEach(() => unsubscribes.forEach(fn => onErrorResumeNext(fn))); 21 | 22 | describe('channel returned 404 on post activity after connected', () => { 23 | let directLine; 24 | let proxyDomain; 25 | let proxyPort; 26 | 27 | beforeEach(async () => { 28 | proxyPort = await getPort(); 29 | proxyDomain = `http://localhost:${proxyPort}/v3/directline`; 30 | }); 31 | 32 | describe('using REST', () => { 33 | beforeEach(() => jest.setTimeout(timeouts.rest)); 34 | 35 | test('with secret', async () => { 36 | directLine = await createDirectLine.forREST({ token: false }, { domain: proxyDomain }); 37 | }); 38 | 39 | test('with token', async () => { 40 | directLine = await createDirectLine.forREST({ token: true }, { domain: proxyDomain }); 41 | }); 42 | }); 43 | 44 | // test('using Streaming Extensions', async () => { 45 | // jest.setTimeout(timeouts.webSocket); 46 | // directLine = await createDirectLine.forStreamingExtensions(); 47 | // }); 48 | 49 | describe('using Web Socket', () => { 50 | beforeEach(() => jest.setTimeout(timeouts.webSocket)); 51 | 52 | test('with secret', async () => { 53 | directLine = await createDirectLine.forWebSocket({ token: false }, { domain: proxyDomain }); 54 | }); 55 | 56 | test('with token', async () => { 57 | directLine = await createDirectLine.forWebSocket({ token: false }, { domain: proxyDomain }); 58 | }); 59 | }); 60 | 61 | afterEach(async () => { 62 | // If directLine object is undefined, that means the test is failing. 63 | if (!directLine) { 64 | return; 65 | } 66 | 67 | let lastConnectionStatus; 68 | 69 | const connectionStatusSubscription = directLine.connectionStatus$.subscribe({ 70 | next(value) { 71 | lastConnectionStatus = value; 72 | } 73 | }); 74 | 75 | unsubscribes.push(connectionStatusSubscription.unsubscribe.bind(connectionStatusSubscription)); 76 | 77 | let alwaysReturn404; 78 | 79 | const { unsubscribe } = await createDirectLineForwarder(proxyPort, (req, res, next) => { 80 | if (req.method !== 'OPTIONS' && alwaysReturn404) { 81 | // JSDOM requires all HTTP response, including those already pre-flighted, to have "Access-Control-Allow-Origin". 82 | // https://github.com/jsdom/jsdom/issues/2024 83 | res.setHeader('Access-Control-Allow-Origin', req.headers.origin || '*'); 84 | res.statusCode = 404; 85 | res.end(); 86 | } else { 87 | next(); 88 | } 89 | }); 90 | 91 | unsubscribes.push(unsubscribe); 92 | unsubscribes.push(directLine.end.bind(directLine)); 93 | unsubscribes.push(await waitForConnected(directLine)); 94 | 95 | await Promise.all([ 96 | postActivity(directLine, { text: 'Hello, World!', type: 'message' }), 97 | waitForBotToEcho(directLine, ({ text }) => text === 'Hello, World!') 98 | ]); 99 | 100 | alwaysReturn404 = true; 101 | 102 | await expect(postActivity(directLine, { text: 'Should not be sent', type: 'message' })).rejects.toThrow(); 103 | 104 | // After post failed, it should stop polling and end all connections 105 | 106 | // TODO: Currently not working on REST/WS 107 | // expect(lastConnectionStatus).not.toBe(2); 108 | }); 109 | }); 110 | }); 111 | -------------------------------------------------------------------------------- /__tests__/unhappy.setUserIdAfterConnect.js: -------------------------------------------------------------------------------- 1 | /** @jest-environment ./__tests__/setup/jsdomEnvironmentWithProxy */ 2 | 3 | import 'dotenv/config'; 4 | 5 | import onErrorResumeNext from 'on-error-resume-next'; 6 | 7 | import { timeouts } from './constants.json'; 8 | import * as createDirectLine from './setup/createDirectLine'; 9 | import waitForConnected from './setup/waitForConnected'; 10 | 11 | describe('Unhappy path', () => { 12 | let unsubscribes; 13 | 14 | beforeEach(() => unsubscribes = []); 15 | afterEach(() => unsubscribes.forEach(fn => onErrorResumeNext(fn))); 16 | 17 | describe('should receive the welcome message from bot', () => { 18 | let directLine; 19 | 20 | describe('using REST', () => { 21 | beforeEach(() => jest.setTimeout(timeouts.rest)); 22 | 23 | test('with secret', async () => { 24 | directLine = await createDirectLine.forREST({ token: false }); 25 | }); 26 | }); 27 | 28 | describe('using Web Socket', () => { 29 | beforeEach(() => jest.setTimeout(timeouts.webSocket)); 30 | 31 | test('with secret', async () => { 32 | directLine = await createDirectLine.forWebSocket({ token: false }); 33 | }); 34 | }); 35 | 36 | afterEach(async () => { 37 | // If directLine object is undefined, that means the test is failing. 38 | if (!directLine) { return; } 39 | 40 | unsubscribes.push(directLine.end.bind(directLine)); 41 | unsubscribes.push(await waitForConnected(directLine)); 42 | 43 | expect(() => directLine.setUserId('e_test')).toThrowError('DirectLineJS: It is connected, we cannot set user id.'); 44 | }); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "test": { 4 | "plugins": ["babel-plugin-istanbul"] 5 | } 6 | }, 7 | "plugins": [ 8 | "@babel/plugin-transform-runtime", 9 | [ 10 | "transform-inline-environment-variables", 11 | { 12 | "include": ["npm_package_version"] 13 | } 14 | ] 15 | ], 16 | "presets": [ 17 | [ 18 | "@babel/preset-env", 19 | { 20 | "forceAllTransforms": true, 21 | "modules": "commonjs" 22 | } 23 | ], 24 | "@babel/preset-typescript" 25 | ], 26 | "sourceMaps": "inline" 27 | } 28 | -------------------------------------------------------------------------------- /docs/API.md: -------------------------------------------------------------------------------- 1 | # API 2 | 3 | DirectLineJS is a chat adapter. It is designed as a state machine with specific behaviors. 4 | 5 | # State machine 6 | 7 | The state is defined by the connection status observable (a.k.a. `connectionStatus$`). 8 | 9 | ```mermaid 10 | stateDiagram-v2 11 | [*] --> Uninitialized: Constructor 12 | Ended --> [*] 13 | 14 | Uninitialized --> Connecting: Subscribe to activity$ 15 | Connecting --> Online: Connected 16 | Online --> Connecting: Disconnected and will retry 17 | Online --> FailedToConnect: Disconnected while retries exhausted 18 | Online --> Ended: Call end() 19 | Connecting --> FailedToConnect: Retry failed and retries exhausted 20 | FailedToConnect --> Ended: Call end() 21 | Connecting --> Ended: Call end() 22 | Uninitialized --> Ended: Call end() 23 | FailedToConnect --> Connecting: Call reconnect() 24 | ``` 25 | 26 | ## Uninitialized 27 | 28 | This is the initial state of the chat adapter. 29 | 30 | ### From states 31 | 32 | - Start terminator: when the chat adapter object is created 33 | - Call to `new DirectLine()` 34 | 35 | ## Connecting 36 | 37 | When the chat adapter is establishing a connection to the bot. 38 | 39 | To prevent service overloading, a backoff must be introduced for retries. 40 | 41 | When retrying to connect, it should first transit to this state, before performing the backoff (a.k.a. sleep). 42 | 43 | The chat adapter will not transit from `Connecting` to `Connecting` when retrying multiple times. It is done transparently and cannot be observed. 44 | 45 | ### From states 46 | 47 | - `Uninitialized`: when subscribing to `activity$` 48 | - Call to `directLine.activity$.subscribe()` 49 | - `Online`: when disconnected while retry is possible 50 | - `FailedToConnect`: when explicitly reconnect 51 | - Call to `directLine.reconnect()` 52 | 53 | ## Online 54 | 55 | When the chat adapter is connected to the bot. 56 | 57 | A connection will be marked as "stable" after it has been in this state for more than 1 minute. A stable connection has these benefits: 58 | 59 | - No backoff at `Connecting` state at the first attempt 60 | - Retry counter is reset 61 | 62 | ### From states 63 | 64 | - `Connecting`: when connection established 65 | 66 | ## FailedToConnect 67 | 68 | When all retry attempts are exhausted. 69 | 70 | ### From states 71 | 72 | - `Connecting`: retrying connection and failed to connect after all retry attempts are exhuasted 73 | - `Online`: when disconnected and all retry attempts are exhausted 74 | 75 | ## Ended 76 | 77 | When `end()` is called. 78 | 79 | The activity observable (a.k.a. `activity$`) will be completed. 80 | 81 | The connection status observable (a.k.a. `connectionStatus$`) will be completed after transitioned into `Ended`. 82 | 83 | After transitioned to this state, all resources should be released. 84 | 85 | ### From states 86 | 87 | - `Uninitialized`: call `end()` before subscribing to `activity$` 88 | - `Connecting`: call `end()` while it is attempting to connect 89 | - `Online`: call `end()` while it is connected 90 | - `FailedToConnect`: call `end()` after all retry attempts are exhausted 91 | 92 | # Scenarios/behaviors 93 | 94 | ## Establishing connection 95 | 96 | To establish the connection, subscribe to the activity observable (a.k.a. `activity$`). 97 | 98 | Subscribing to the connection status observable (a.k.a. `connectionStatus$`) will *not* establish the connection. 99 | 100 | ## Subscribing to activities 101 | 102 | To subscribe to incoming activities, call `activity$.subscribe()`. The `activity$` is an observable. 103 | 104 | Unlike ES Observable, the `activity$` is *shared* amongst subscribers. The first subscriber will kick off connection and observe all activities. Subscribers which joined later will only observe activities from the time they are subscribed. 105 | 106 | Similarly, `connectionStatus$` is also a *shared* observable. 107 | 108 | ## Sending an activity 109 | 110 | To send an activity to the bot, call `postActivity()`. This would put the activity on the bot queue and returns an observable. 111 | 112 | There will be two acknowledgements for the activity: 113 | 114 | - Acknowledgement when the activity is on the bot queue 115 | - Acknowledgement when the bot finished processing the activity (a.k.a. read receipt) 116 | 117 | Due to distributed nature of the system, these acknowledgements may come in random order. 118 | 119 | ### The activity is on the bot queue 120 | 121 | The observable returned by the `postActivity()` call will tell if the activity is successfully queued or not. 122 | 123 | - When the queue operation completed successfully: 124 | - The activity ID generated by the bot or service will be observed 125 | - Then, the observable is completed 126 | - When the queue operation failed: 127 | - The observable will be errored out 128 | 129 | ### The bot finished processing the activity 130 | 131 | The activity will be echoed back via the activity observable (a.k.a. `activity$`). It may come with more details about the activity, such as activity ID, channel ID, conversation ID, timestamp, etc. 132 | 133 | Before the bot finished processing of the activity, it may send responses. In other words, bot response could arrive sooner than the read receipt. The `replyToId` in the activity should be used to determine which activity the bot is responding to. 134 | 135 | ## Retrying connections 136 | 137 | Connection should be automatically retried. A backoff should be applied if the network environment is unstable. 138 | 139 | If the connection is stable, it should reset retry counter. 140 | 141 | Depends on the service, a stable connection could means: 142 | 143 | - a minute has been passed; 144 | - a certain number of activities are exchanged; 145 | - a certain size of packets are exchanged. 146 | 147 | ### Optional: improve retry experience 148 | 149 | If the connection was stable and is disconnected, it should retry the connection without backoff in its first attempt. This can be done through retry counter or exponential backoff. 150 | 151 | ## Reconnecting 152 | 153 | When all retry attempts are exhausted, the chat adapter will "hibernate". Calling `reconnect()` will wake up and revive the chat adapter and reset the retry counter. 154 | 155 | `reconnect()` can be called multiple times, as long as the chat adapter is not ended. 156 | 157 | Calling `reconnect()` while the chat adapter is active, should do nothing. 158 | 159 | Thus, if the chat adapter is not ended, `reconnect()` is safe to call immediately after app switching or visibility change, regardless of the connection status. 160 | 161 | ## Ending the chat adapter 162 | 163 | When `end()` is called, all resources held by the chat adapter must be released. Further `postActivity()` or `reconnect()` should fail. 164 | -------------------------------------------------------------------------------- /docs/media/FrameWorkDirectLineJS@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/BotFramework-DirectLineJS/13f7a42975e36b42e52fa995306a46d396d29986/docs/media/FrameWorkDirectLineJS@1x.png -------------------------------------------------------------------------------- /docs/media/README.md: -------------------------------------------------------------------------------- 1 | Assets for BotFramework-DirectLineJS site 2 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | setupFilesAfterEnv: ['./__tests__/directLineStreaming/__setup__/expect/activityContaining.ts'], 3 | testMatch: ['**/__tests__/**/*.[jt]s?(x)', '**/?(*.)(spec|test).[jt]s?(x)'], 4 | testPathIgnorePatterns: ['/__tests__/setup/', '/__tests__/directLineStreaming/__setup__/'], 5 | 6 | // Some packages enforce ESM but jest@27.0.6 does not fully support ESM yet. 7 | // We need to transpile these ESM packages back to CommonJS when importing them under Jest: 8 | // - botframework-streaming 9 | // - p-defer 10 | // - uuid 11 | // Jest default is ["/node_modules/", "\\.pnp\\.[^\\\/]+$"]. 12 | // https://jestjs.io/docs/configuration#transformignorepatterns-arraystring 13 | transformIgnorePatterns: ['\\/node_modules\\/(?!(botframework-streaming|p-defer|uuid)\\/)', '\\.pnp\\.[^\\/]+$'] 14 | }; 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "botframework-directlinejs", 3 | "version": "0.15.7-0", 4 | "description": "Client library for the Microsoft Bot Framework Direct Line 3.0 protocol", 5 | "files": [ 6 | "dist/**/*", 7 | "lib/**/*" 8 | ], 9 | "main": "lib/directLine.js", 10 | "types": "lib/directLine.d.ts", 11 | "scripts": { 12 | "build": "npm run build:typecheck && npm run build:babel -- --env-name test && npm run build:webpack -- --config webpack-development.config.js", 13 | "build:babel": "babel --extensions .js,.ts --ignore src/**/*.spec.js,src/**/*.spec.ts,src/**/*.test.js,src/**/*.test.ts --out-dir lib src", 14 | "build:typecheck": "tsc", 15 | "build:webpack": "webpack", 16 | "clean": "rimraf dist lib", 17 | "prepublishOnly": "npm run build:typecheck && npm run build:babel && npm run build:webpack", 18 | "start": "npm run build && concurrently --names \"babel,typecheck,webpack\" \"npm run build:babel -- --watch\" \"npm run build:typecheck -- --preserveWatchOutput --watch\" \"npm run build:webpack -- --config webpack-watch.config.js --watch\"", 19 | "test": "jest --silent", 20 | "watch": "npm run start" 21 | }, 22 | "repository": { 23 | "type": "git", 24 | "url": "git+https://github.com/Microsoft/BotFramework-DirectLineJS.git" 25 | }, 26 | "author": "Microsoft Corporation", 27 | "license": "MIT", 28 | "dependencies": { 29 | "@babel/runtime": "7.26.10", 30 | "botframework-streaming": "4.23.0", 31 | "buffer": "6.0.3", 32 | "core-js": "3.15.2", 33 | "cross-fetch": "^3.1.5", 34 | "jwt-decode": "3.1.2", 35 | "rxjs": "5.5.12", 36 | "url-search-params-polyfill": "8.1.1" 37 | }, 38 | "devDependencies": { 39 | "@babel/cli": "^7.14.8", 40 | "@babel/core": "^7.14.8", 41 | "@babel/plugin-transform-runtime": "^7.14.5", 42 | "@babel/preset-env": "^7.14.8", 43 | "@babel/preset-typescript": "^7.14.5", 44 | "@testing-library/dom": "^9.2.0", 45 | "@types/express": "^4.17.17", 46 | "@types/jest": "^29.5.1", 47 | "@types/jsonwebtoken": "^8.5.4", 48 | "@types/node": "^18.15.11", 49 | "@types/p-defer": "^2.0.0", 50 | "@types/ws": "^8.5.4", 51 | "babel-jest": "^29.5.0", 52 | "babel-loader": "^8.2.2", 53 | "babel-plugin-istanbul": "^6.0.0", 54 | "babel-plugin-transform-inline-environment-variables": "^0.4.3", 55 | "concurrently": "^6.2.0", 56 | "dotenv": "^10.0.0", 57 | "event-target-shim": "^6.0.2", 58 | "express": "^4.18.2", 59 | "get-port": "^5.1.1", 60 | "global-agent": "^2.2.0", 61 | "has-resolved": "^1.1.0", 62 | "http-proxy": "^1.18.1", 63 | "http-proxy-middleware": "^2.0.6", 64 | "jest": "^29.5.0", 65 | "jest-environment-jsdom": "^29.5.0", 66 | "jsdom": "^16.6.0", 67 | "nock": "^13.1.1", 68 | "node-fetch": "^2.6.7", 69 | "on-error-resume-next": "^1.1.0", 70 | "path-to-regexp": "^6.2.1", 71 | "restify": "^11.0.0", 72 | "rimraf": "^3.0.2", 73 | "simple-update-in": "^2.2.0", 74 | "typescript": "^4.9.5", 75 | "webpack": "^5.76.2", 76 | "webpack-cli": "^4.7.2", 77 | "webpack-stats-plugin": "^1.0.3", 78 | "ws": "^8.13.0" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/createDeferred.ts: -------------------------------------------------------------------------------- 1 | type Deferred = { 2 | promise: Promise; 3 | reject: (reason?: any) => void; 4 | resolve: (value: T | PromiseLike) => void; 5 | }; 6 | 7 | function createDeferred(): Deferred { 8 | const deferred: Partial> = {}; 9 | 10 | deferred.promise = new Promise((resolve, reject) => { 11 | deferred.reject = reject; 12 | deferred.resolve = resolve; 13 | }); 14 | 15 | return deferred as Deferred; 16 | } 17 | 18 | export default createDeferred; 19 | export type { Deferred }; 20 | -------------------------------------------------------------------------------- /src/dedupeFilenames.spec.js: -------------------------------------------------------------------------------- 1 | // @jest-environment node 2 | 3 | import dedupeFilenames from './dedupeFilenames'; 4 | 5 | test('Deduping "abc.gif", "def.gif"', () => { 6 | const actual = dedupeFilenames(['abc.gif', 'def.gif']); 7 | 8 | expect(actual).toEqual(['abc.gif', 'def.gif']); 9 | }); 10 | 11 | test('Deduping "abc.gif", "abc.gif"', () => { 12 | const actual = dedupeFilenames(['abc.gif', 'abc.gif']); 13 | 14 | expect(actual).toEqual(['abc.gif', 'abc (1).gif']); 15 | }); 16 | 17 | test('Deduping "abc.def.gif", "abc.def.gif"', () => { 18 | const actual = dedupeFilenames(['abc.def.gif', 'abc.def.gif']); 19 | 20 | expect(actual).toEqual(['abc.def.gif', 'abc.def (1).gif']); 21 | }); 22 | 23 | test('Deduping ".gitignore", ".gitignore"', () => { 24 | const actual = dedupeFilenames(['.gitignore', '.gitignore']); 25 | 26 | expect(actual).toEqual(['.gitignore', '(1).gitignore']); 27 | }); 28 | 29 | test('Deduping "Dockerfile", "Dockerfile"', () => { 30 | const actual = dedupeFilenames(['Dockerfile', 'Dockerfile']); 31 | 32 | expect(actual).toEqual(['Dockerfile', 'Dockerfile (1)']); 33 | }); 34 | 35 | test('Deduping "", ""', () => { 36 | const actual = dedupeFilenames(['', '']); 37 | 38 | expect(actual).toEqual(['', '(1)']); 39 | }); 40 | -------------------------------------------------------------------------------- /src/dedupeFilenames.ts: -------------------------------------------------------------------------------- 1 | import parseFilename from './parseFilename'; 2 | 3 | export default function dedupeFilenames(array: string[]) { 4 | const nextArray: string[] = []; 5 | 6 | array.forEach(value => { 7 | const { extname, name } = parseFilename(value); 8 | let count = 0; 9 | let nextValue = value; 10 | 11 | while (nextArray.includes(nextValue)) { 12 | nextValue = [name, `(${ (++count) })`].filter(segment => segment).join(' ') + extname; 13 | } 14 | 15 | nextArray.push(nextValue); 16 | }); 17 | 18 | return nextArray; 19 | } 20 | -------------------------------------------------------------------------------- /src/directLine.mock.ts: -------------------------------------------------------------------------------- 1 | import * as DirectLineExport from "./directLine"; 2 | import { TestScheduler, Observable } from "rxjs"; 3 | import { AjaxCreationMethod, AjaxRequest, AjaxResponse } from "rxjs/observable/dom/AjaxObservable"; 4 | import { URL, URLSearchParams } from 'url'; 5 | 6 | // MOCK helpers 7 | 8 | const notImplemented = (): never => { throw new Error('not implemented') }; 9 | 10 | // MOCK Activity 11 | 12 | export const mockActivity = (text: string): DirectLineExport.Activity => ({ type: 'message', from: { id: 'sender' }, text }); 13 | 14 | // MOCK DirectLine Server (shared state used by Observable.ajax and WebSocket mocks) 15 | 16 | interface ActivitySocket { 17 | play: (start: number, after: number) => void; 18 | } 19 | 20 | export type Socket = WebSocket & ActivitySocket; 21 | 22 | export interface Conversation { 23 | sockets: Set; 24 | conversationId: string; 25 | history: Array; 26 | token: string; 27 | } 28 | 29 | export interface Server { 30 | scheduler: TestScheduler; 31 | conversation: Conversation; 32 | } 33 | 34 | const tokenPrefix = 'token'; 35 | 36 | export const mockServer = (scheduler: TestScheduler): Server => ({ 37 | scheduler, 38 | conversation: { 39 | sockets: new Set(), 40 | conversationId: 'OneConversation', 41 | history: [], 42 | token: tokenPrefix + '1', 43 | } 44 | }); 45 | 46 | const tokenResponse = (server: Server, request: AjaxRequest): AjaxResponse | null => { 47 | const { headers } = request; 48 | const authorization = headers['Authorization']; 49 | if (authorization === `Bearer ${server.conversation.token}`) { 50 | return null; 51 | } 52 | 53 | const response: Partial = { 54 | status: 403, 55 | } 56 | 57 | return response as AjaxResponse; 58 | } 59 | 60 | export const injectClose = (server: Server): void => 61 | server.conversation.sockets.forEach(s => s.onclose(new CloseEvent('close'))); 62 | 63 | export const injectNewToken = (server: Server): void => { 64 | const { conversation } = server; 65 | const suffix = Number.parseInt(conversation.token.substring(tokenPrefix.length), 10) + 1 66 | conversation.token = tokenPrefix + suffix; 67 | } 68 | 69 | const keyWatermark = 'watermark'; 70 | 71 | type ajaxType = (urlOrRequest: string | AjaxRequest) => AjaxResponse; 72 | 73 | // MOCK Observable.ajax (uses shared state in Server) 74 | 75 | export const mockAjax = (server: Server, customAjax?: ajaxType): AjaxCreationMethod => { 76 | 77 | const uriBase = new URL('https://directline.botframework.com/v3/directline/'); 78 | const createStreamUrl = (watermark: number): string => { 79 | const uri = new URL('conversations/stream', uriBase); 80 | if (watermark > 0) { 81 | const params = new URLSearchParams(); 82 | params.append(keyWatermark, watermark.toString(10)); 83 | uri.search = params.toString(); 84 | } 85 | 86 | return uri.toString(); 87 | } 88 | 89 | const jax = customAjax || ((urlOrRequest: string | AjaxRequest): AjaxResponse => { 90 | if (typeof urlOrRequest === 'string') { 91 | throw new Error(); 92 | } 93 | 94 | const uri = new URL(urlOrRequest.url); 95 | 96 | const { pathname, searchParams } = uri; 97 | 98 | const parts = pathname.split('/'); 99 | 100 | if (parts[3] === 'tokens' && parts[4] === 'refresh') { 101 | 102 | const response: Partial = { 103 | response: { token: server.conversation.token } 104 | }; 105 | 106 | return response as AjaxResponse; 107 | } 108 | 109 | if (parts[3] !== 'conversations') { 110 | throw new Error(); 111 | } 112 | 113 | if (parts.length === 4) { 114 | const conversation: DirectLineExport.Conversation = { 115 | conversationId: server.conversation.conversationId, 116 | token: server.conversation.token, 117 | streamUrl: createStreamUrl(0), 118 | }; 119 | 120 | const response: Partial = { 121 | response: conversation, 122 | } 123 | 124 | return response as AjaxResponse; 125 | } 126 | 127 | if (parts[4] !== server.conversation.conversationId) { 128 | throw new Error(); 129 | } 130 | 131 | if (parts[5] === 'activities') { 132 | const responseToken = tokenResponse(server, urlOrRequest); 133 | if (responseToken !== null) { 134 | return responseToken; 135 | } 136 | 137 | const activity: DirectLineExport.Activity = urlOrRequest.body; 138 | 139 | const after = server.conversation.history.push(activity); 140 | const start = after - 1; 141 | 142 | for (const socket of server.conversation.sockets) { 143 | socket.play(start, after); 144 | } 145 | 146 | const response: Partial = { 147 | response: { id: 'messageId' }, 148 | } 149 | 150 | return response as AjaxResponse; 151 | } 152 | else if (parts.length === 5) { 153 | const responseToken = tokenResponse(server, urlOrRequest); 154 | if (responseToken !== null) { 155 | return responseToken; 156 | } 157 | 158 | const watermark = searchParams.get('watermark'); 159 | const start = Number.parseInt(watermark, 10); 160 | 161 | const conversation: DirectLineExport.Conversation = { 162 | conversationId: server.conversation.conversationId, 163 | token: server.conversation.token, 164 | streamUrl: createStreamUrl(start), 165 | }; 166 | 167 | const response: Partial = { 168 | response: conversation, 169 | } 170 | 171 | return response as AjaxResponse; 172 | } 173 | 174 | throw new Error(); 175 | }); 176 | 177 | const method = (urlOrRequest: string | AjaxRequest): Observable => 178 | new Observable(subscriber => { 179 | try { 180 | subscriber.next(jax(urlOrRequest)); 181 | subscriber.complete(); 182 | } 183 | catch (error) { 184 | subscriber.error(error); 185 | } 186 | }); 187 | 188 | type ValueType = { 189 | [K in keyof T]: T[K] extends V ? T[K] : never; 190 | } 191 | 192 | type Properties = ValueType; 193 | 194 | const properties: Properties = { 195 | get: (url: string, headers?: Object): Observable => notImplemented(), 196 | post: (url: string, body?: any, headers?: Object): Observable => notImplemented(), 197 | put: (url: string, body?: any, headers?: Object): Observable => notImplemented(), 198 | patch: (url: string, body?: any, headers?: Object): Observable => notImplemented(), 199 | delete: (url: string, headers?: Object): Observable => notImplemented(), 200 | getJSON: (url: string, headers?: Object) => notImplemented(), 201 | }; 202 | 203 | return Object.assign(method, properties); 204 | } 205 | 206 | // MOCK WebSocket (uses shared state in Server) 207 | 208 | type WebSocketConstructor = typeof WebSocket; 209 | type EventHandler = (this: WebSocket, ev: E) => any; 210 | 211 | export const mockWebSocket = (server: Server): WebSocketConstructor => 212 | class MockWebSocket implements WebSocket, ActivitySocket { 213 | constructor(url: string, protocols?: string | string[]) { 214 | 215 | server.scheduler.schedule(() => { 216 | this.readyState = WebSocket.CONNECTING; 217 | server.conversation.sockets.add(this); 218 | this.onopen(new Event('open')); 219 | this.readyState = WebSocket.OPEN; 220 | const uri = new URL(url); 221 | const watermark = uri.searchParams.get(keyWatermark) 222 | if (watermark !== null) { 223 | const start = Number.parseInt(watermark, 10); 224 | this.play(start, server.conversation.history.length); 225 | } 226 | }); 227 | } 228 | 229 | play(start: number, after: number) { 230 | 231 | const { conversation: { history } } = server; 232 | const activities = history.slice(start, after); 233 | const watermark = history.length.toString(); 234 | const activityGroup: DirectLineExport.ActivityGroup = { 235 | activities, 236 | watermark, 237 | } 238 | 239 | const message = new MessageEvent('type', { data: JSON.stringify(activityGroup) }); 240 | 241 | this.onmessage(message); 242 | } 243 | 244 | binaryType: BinaryType = 'arraybuffer'; 245 | readonly bufferedAmount: number = 0; 246 | readonly extensions: string = ''; 247 | readonly protocol: string = 'https'; 248 | readyState: number = WebSocket.CLOSED; 249 | readonly url: string = ''; 250 | readonly CLOSED: number = WebSocket.CLOSED; 251 | readonly CLOSING: number = WebSocket.CLOSING; 252 | readonly CONNECTING: number = WebSocket.CONNECTING; 253 | readonly OPEN: number = WebSocket.OPEN; 254 | 255 | onclose: EventHandler; 256 | onerror: EventHandler; 257 | onmessage: EventHandler; 258 | onopen: EventHandler; 259 | 260 | close(code?: number, reason?: string): void { 261 | this.readyState = WebSocket.CLOSING; 262 | this.onclose(new CloseEvent('close')) 263 | server.conversation.sockets.delete(this); 264 | this.readyState = WebSocket.CLOSED; 265 | } 266 | 267 | send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void { 268 | } 269 | 270 | addEventListener() { throw new Error(); } 271 | removeEventListener() { throw new Error(); } 272 | dispatchEvent(): boolean { throw new Error(); } 273 | 274 | static CLOSED = WebSocket.CLOSED; 275 | static CLOSING = WebSocket.CLOSING; 276 | static CONNECTING = WebSocket.CONNECTING; 277 | static OPEN = WebSocket.OPEN; 278 | }; 279 | 280 | // MOCK services (top-level aggregation of all mocks) 281 | 282 | export const mockServices = (server: Server, scheduler: TestScheduler): DirectLineExport.Services => ({ 283 | scheduler, 284 | WebSocket: mockWebSocket(server), 285 | ajax: mockAjax(server), 286 | random: () => 0, 287 | }); 288 | -------------------------------------------------------------------------------- /src/directLine.test.ts: -------------------------------------------------------------------------------- 1 | /** @jest-environment ./__tests__/setup/jsdomEnvironmentWithProxy */ 2 | 3 | import * as DirectLineExport from "./directLine"; 4 | import * as DirectLineMock from './directLine.mock'; 5 | import { TestScheduler, Observable, Subscription, AjaxResponse } from "rxjs"; 6 | // @ts-ignore 7 | import { version } from "../package.json"; 8 | 9 | declare var process: { 10 | arch: string; 11 | env: { 12 | VERSION: string; 13 | }; 14 | platform: string; 15 | release: string; 16 | version: string; 17 | }; 18 | 19 | test("#setConnectionStatusFallback", () => { 20 | const { DirectLine } = DirectLineExport; 21 | expect(typeof DirectLine.prototype.setConnectionStatusFallback).toBe("function") 22 | const { setConnectionStatusFallback } = DirectLine.prototype; 23 | const testFallback = setConnectionStatusFallback(0, 1); 24 | let idx = 4; 25 | while (idx--) { 26 | expect(testFallback(0)).toBe(0); 27 | } 28 | // fallback will be triggered 29 | expect(testFallback(0)).toBe(1); 30 | idx = 4; 31 | while (idx--) { 32 | expect(testFallback(0)).toBe(0); 33 | } 34 | expect(testFallback(0)).toBe(1); 35 | }); 36 | 37 | describe("#commonHeaders", () => { 38 | const botAgent = `DirectLine/3.0 (directlinejs; custom-bot-agent ${version})`; 39 | let botConnection; 40 | 41 | beforeEach(() => { 42 | global.process.env.VERSION = "test-version"; 43 | const { DirectLine } = DirectLineExport; 44 | botConnection = new DirectLine({ token: "secret-token", botAgent: "custom-bot-agent" }); 45 | }); 46 | 47 | test('appends browser user agent when in a browser', () => { 48 | // @ts-ignore 49 | expect(botConnection.commonHeaders()).toEqual({ 50 | "Authorization": "Bearer secret-token", 51 | "x-ms-bot-agent": botAgent 52 | }); 53 | }) 54 | 55 | test.skip('appends node environment agent when in node', () => { 56 | // @ts-ignore 57 | delete window.navigator 58 | // @ts-ignore 59 | const os = require('os'); 60 | const { arch, platform, version } = process; 61 | 62 | // @ts-ignore 63 | expect(botConnection.commonHeaders()).toEqual({ 64 | "Authorization": "Bearer secret-token", 65 | "User-Agent": `${botAgent} (Node.js,Version=${version}; ${platform} ${os.release()}; ${arch})`, 66 | "x-ms-bot-agent": botAgent 67 | }); 68 | }) 69 | }); 70 | 71 | describe('MockSuite', () => { 72 | 73 | const lazyConcat = (items: Iterable>): Observable => 74 | new Observable(subscriber => { 75 | const iterator = items[Symbol.iterator](); 76 | let inner: Subscription | undefined; 77 | 78 | const pump = () => { 79 | try { 80 | const result = iterator.next(); 81 | if (result.done === true) { 82 | subscriber.complete(); 83 | } 84 | else { 85 | inner = result.value.subscribe( 86 | value => subscriber.next(value), 87 | error => subscriber.error(error), 88 | pump 89 | ); 90 | } 91 | } 92 | catch (error) { 93 | subscriber.error(error); 94 | } 95 | }; 96 | 97 | pump(); 98 | 99 | return () => { 100 | if (typeof inner !== 'undefined') { 101 | inner.unsubscribe(); 102 | } 103 | }; 104 | }); 105 | 106 | let scheduler: TestScheduler; 107 | let server: DirectLineMock.Server; 108 | let services: DirectLineExport.Services; 109 | let subscriptions: Array; 110 | let directline: DirectLineExport.DirectLine; 111 | 112 | beforeEach(() => { 113 | scheduler = new TestScheduler((actual, expected) => expect(expected).toBe(actual)); 114 | scheduler.maxFrames = 60 * 1000; 115 | server = DirectLineMock.mockServer(scheduler); 116 | services = DirectLineMock.mockServices(server, scheduler); 117 | directline = new DirectLineExport.DirectLine(services); 118 | subscriptions = []; 119 | }); 120 | 121 | afterEach(() => { 122 | for (const subscription of subscriptions) { 123 | subscription.unsubscribe(); 124 | } 125 | }) 126 | 127 | const expected = { 128 | x: DirectLineMock.mockActivity('x'), 129 | y: DirectLineMock.mockActivity('y'), 130 | z: DirectLineMock.mockActivity('z'), 131 | }; 132 | 133 | test('HappyPath', () => { 134 | // arrange 135 | 136 | const scenario = function* (): IterableIterator> { 137 | yield Observable.timer(200, scheduler); 138 | yield directline.postActivity(expected.x); 139 | yield Observable.timer(200, scheduler); 140 | yield directline.postActivity(expected.y); 141 | yield Observable.timer(200, scheduler); 142 | }; 143 | 144 | subscriptions.push(lazyConcat(scenario()).observeOn(scheduler).subscribe()); 145 | 146 | const actual: Array = []; 147 | subscriptions.push(directline.activity$.subscribe(a => actual.push(a))); 148 | 149 | // act 150 | 151 | scheduler.flush(); 152 | 153 | // assert 154 | 155 | expect(actual).toStrictEqual([expected.x, expected.y]); 156 | }); 157 | 158 | test('ReconnectOnClose', () => { 159 | // arrange 160 | 161 | const scenario = function* (): IterableIterator> { 162 | yield Observable.timer(200, scheduler); 163 | yield directline.postActivity(expected.x); 164 | DirectLineMock.injectClose(server); 165 | yield Observable.timer(200, scheduler); 166 | yield directline.postActivity(expected.y); 167 | yield Observable.timer(200, scheduler); 168 | }; 169 | 170 | subscriptions.push(lazyConcat(scenario()).observeOn(scheduler).subscribe()); 171 | 172 | const actual: Array = []; 173 | subscriptions.push(directline.activity$.subscribe(a => actual.push(a))); 174 | 175 | // act 176 | 177 | scheduler.flush(); 178 | 179 | // assert 180 | 181 | expect(actual).toStrictEqual([expected.x, expected.y]); 182 | }); 183 | 184 | test('BotAgentWithMocks', () => { 185 | const expected: string = `DirectLine/3.0 (directlinejs ${version})`; 186 | 187 | //@ts-ignore 188 | const actual: string = directline.commonHeaders()["x-ms-bot-agent"]; 189 | expect(actual).toStrictEqual(expected) 190 | }); 191 | 192 | test('RetryAfterHeader', () => { 193 | services.ajax = DirectLineMock.mockAjax(server, (urlOrRequest) => { 194 | 195 | if(typeof urlOrRequest === 'string'){ 196 | throw new Error(); 197 | } 198 | 199 | if(urlOrRequest.url && urlOrRequest.url.indexOf(server.conversation.conversationId)>0){ 200 | const response: Partial = { 201 | status: 429, 202 | xhr:{ 203 | getResponseHeader: (name) => "10" 204 | } as XMLHttpRequest 205 | }; 206 | const error = new Error('Ajax Error'); 207 | throw Object.assign(error, response); 208 | } 209 | else if(urlOrRequest.url && urlOrRequest.url.indexOf('/conversations') > 0){ 210 | // start conversation 211 | const response: Partial = { 212 | response: server.conversation, 213 | status: 201, 214 | xhr: { 215 | getResponseHeader: (name) => 'n/a' 216 | } as XMLHttpRequest 217 | }; 218 | return response as AjaxResponse; 219 | } 220 | throw new Error(); 221 | }); 222 | directline = new DirectLineExport.DirectLine(services); 223 | 224 | let startTime: number; 225 | let endTime: number; 226 | const scenario = function* (): IterableIterator> { 227 | yield Observable.timer(200, scheduler); 228 | startTime = scheduler.now(); 229 | yield directline.postActivity(expected.x); 230 | }; 231 | 232 | let actualError: Error; 233 | try{ 234 | subscriptions.push(lazyConcat(scenario()).observeOn(scheduler).subscribe()); 235 | scheduler.flush(); 236 | } 237 | catch(error){ 238 | actualError = error; 239 | endTime = scheduler.now(); 240 | } 241 | expect(actualError.message).toStrictEqual('Ajax Error'); 242 | // @ts-ignore 243 | expect(actualError.status).toStrictEqual(429); 244 | expect(endTime - startTime).toStrictEqual(10); 245 | }); 246 | }); 247 | -------------------------------------------------------------------------------- /src/parseFilename.js: -------------------------------------------------------------------------------- 1 | export default function parseFilename(filename) { 2 | if (!filename) { 3 | return { 4 | extname: '', 5 | name: '' 6 | }; 7 | } else if (~filename.indexOf('.')) { 8 | const [extensionWithoutDot, ...nameSegments] = filename.split('.').reverse(); 9 | 10 | return { 11 | extname: '.' + extensionWithoutDot, 12 | name: nameSegments.reverse().join('.') 13 | }; 14 | } else { 15 | return { 16 | extname: '', 17 | name: filename 18 | }; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/parseFilename.spec.js: -------------------------------------------------------------------------------- 1 | // @jest-environment node 2 | 3 | import parseFilename from './parseFilename'; 4 | 5 | test('Parsing "abc.gif"', () => { 6 | const actual = parseFilename('abc.gif'); 7 | 8 | expect(actual).toHaveProperty('extname', '.gif'); 9 | expect(actual).toHaveProperty('name', 'abc'); 10 | }); 11 | 12 | test('Parsing "abc.def.gif"', () => { 13 | const actual = parseFilename('abc.def.gif'); 14 | 15 | expect(actual).toHaveProperty('extname', '.gif'); 16 | expect(actual).toHaveProperty('name', 'abc.def'); 17 | }); 18 | 19 | test('Parsing ".gitignore"', () => { 20 | const actual = parseFilename('.gitignore'); 21 | 22 | expect(actual).toHaveProperty('extname', '.gitignore'); 23 | expect(actual).toHaveProperty('name', ''); 24 | }); 25 | 26 | test('Parsing "Dockerfile"', () => { 27 | const actual = parseFilename('Dockerfile'); 28 | 29 | expect(actual).toHaveProperty('extname', ''); 30 | expect(actual).toHaveProperty('name', 'Dockerfile'); 31 | }); 32 | 33 | test('Parsing null', () => { 34 | const actual = parseFilename(null); 35 | 36 | expect(actual).toHaveProperty('extname', ''); 37 | expect(actual).toHaveProperty('name', ''); 38 | }); 39 | 40 | test('Parsing undefined', () => { 41 | const actual = parseFilename(); 42 | 43 | expect(actual).toHaveProperty('extname', ''); 44 | expect(actual).toHaveProperty('name', ''); 45 | }); 46 | 47 | test('Parsing ""', () => { 48 | const actual = parseFilename(''); 49 | 50 | expect(actual).toHaveProperty('extname', ''); 51 | expect(actual).toHaveProperty('name', ''); 52 | }); 53 | -------------------------------------------------------------------------------- /src/streaming/NetworkInformation.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | // This is subset of https://developer.mozilla.org/en-US/docs/Web/API/NetworkInformation. 3 | interface NetworkInformation extends EventTarget { 4 | addEventListener( 5 | type: 'change', 6 | listener: EventListener | EventListenerObject, 7 | options?: AddEventListenerOptions | boolean 8 | ): void; 9 | 10 | removeEventListener( 11 | type: 'change', 12 | listener: EventListener | EventListenerObject, 13 | options?: AddEventListenerOptions | boolean 14 | ): void; 15 | 16 | get type(): 'bluetooth' | 'cellular' | 'ethernet' | 'none' | 'other' | 'unknown' | 'wifi' | 'wimax'; 17 | } 18 | 19 | interface Navigator { 20 | get connection(): NetworkInformation; 21 | } 22 | } 23 | 24 | export {} 25 | -------------------------------------------------------------------------------- /src/streaming/WebSocketClientWithNetworkInformation.spec.ts: -------------------------------------------------------------------------------- 1 | import type { WebSocketClient } from 'botframework-streaming'; 2 | import type ActualWebSocketClientWithNetworkInformation from './WebSocketClientWithNetworkInformation'; 3 | 4 | // Mocked modules are available across the test file. They cannot be unmocked. 5 | // Thus, they are more-or-less similar to import/require. 6 | jest.mock('botframework-streaming', () => ({ 7 | __esmodule: true, 8 | WebSocketClient: class WebSocketClient { 9 | constructor({ disconnectionHandler, requestHandler, url }: WebSocketClientInit) { 10 | this.#disconnectionHandler = disconnectionHandler; 11 | this.#requestHandler = requestHandler; 12 | this.#url = url; 13 | 14 | // Set up mocks. 15 | this.#connect = jest.fn(() => Promise.resolve()); 16 | this.#disconnect = jest.fn(() => this.#disconnectionHandler?.('disconnect() is called')); 17 | } 18 | 19 | #connect: () => Promise; 20 | #disconnect: () => void; 21 | #disconnectionHandler: WebSocketClientInit['disconnectionHandler']; 22 | #requestHandler: WebSocketClientInit['requestHandler']; 23 | #url: string; 24 | 25 | connect(): Promise { 26 | return this.#connect(); 27 | } 28 | 29 | disconnect(): void { 30 | return this.#disconnect(); 31 | } 32 | 33 | get __test__connect(): () => Promise { 34 | return this.#connect; 35 | } 36 | 37 | get __test__disconnect(): () => void { 38 | return this.#disconnect; 39 | } 40 | 41 | get __test__disconnectionHandler(): WebSocketClientInit['disconnectionHandler'] { 42 | return this.#disconnectionHandler; 43 | } 44 | 45 | get __test__requestHandler(): WebSocketClientInit['requestHandler'] { 46 | return this.#requestHandler; 47 | } 48 | 49 | get __test__url(): WebSocketClientInit['url'] { 50 | return this.#url; 51 | } 52 | } 53 | })); 54 | 55 | type NetworkInformationType = 'bluetooth' | 'cellular' | 'ethernet' | 'none' | 'other' | 'unknown' | 'wifi' | 'wimax'; 56 | 57 | class MockNetworkInformation extends EventTarget { 58 | constructor() { 59 | super(); 60 | } 61 | 62 | #type: NetworkInformationType = 'none'; 63 | 64 | get type() { 65 | return this.#type; 66 | } 67 | 68 | set type(value: NetworkInformationType) { 69 | if (this.#type !== value) { 70 | this.#type = value; 71 | this.dispatchEvent(new Event('change')); 72 | } 73 | } 74 | } 75 | 76 | type WebSocketClientInit = ConstructorParameters[0]; 77 | 78 | const disconnectionHandler: WebSocketClientInit['disconnectionHandler'] = jest.fn(); 79 | const requestHandler: WebSocketClientInit['requestHandler'] = { processRequest: jest.fn() }; 80 | const url: string = 'wss://dummy/'; 81 | 82 | let client: WebSocketClient; 83 | let connection: MockNetworkInformation; 84 | 85 | beforeEach(() => { 86 | connection = new MockNetworkInformation(); 87 | 88 | let WebSocketClientWithNetworkInformation: typeof ActualWebSocketClientWithNetworkInformation; 89 | 90 | WebSocketClientWithNetworkInformation = require('./WebSocketClientWithNetworkInformation').default; 91 | 92 | client = new WebSocketClientWithNetworkInformation({ 93 | disconnectionHandler, 94 | networkInformation: connection, 95 | requestHandler, 96 | url 97 | }); 98 | 99 | // Spy on all `console.warn()`. 100 | jest.spyOn(console, 'warn').mockImplementation(() => {}); 101 | }); 102 | 103 | afterEach(() => jest.restoreAllMocks()); 104 | 105 | describe('constructor', () => { 106 | test('should pass `disconnectionHandler`', () => 107 | expect(client['__test__disconnectionHandler']).toBe(disconnectionHandler)); 108 | test('should pass `requestHandler`', () => expect(client['__test__requestHandler']).toBe(requestHandler)); 109 | test('should pass `url`', () => expect(client['__test__url']).toBe(url)); 110 | }); 111 | 112 | describe('initially online', () => { 113 | beforeEach(() => { 114 | connection.type = 'wifi'; 115 | }); 116 | 117 | test('should not call super.connect()', () => expect(client['__test__connect']).toBeCalledTimes(0)); 118 | 119 | describe('when connect() is called', () => { 120 | beforeEach(() => client.connect()); 121 | 122 | test('should call super.connect()', () => expect(client['__test__connect']).toBeCalledTimes(1)); 123 | test('should not call super.disconnect()', () => expect(client['__test__disconnect']).toBeCalledTimes(0)); 124 | test('should not call disconnectionHandler', () => 125 | expect(client['__test__disconnectionHandler']).toBeCalledTimes(0)); 126 | 127 | describe('when offline', () => { 128 | beforeEach(() => { 129 | connection.type = 'none'; 130 | }); 131 | 132 | test('should call super.disconnect()', () => expect(client['__test__disconnect']).toBeCalledTimes(1)); 133 | 134 | // If connected, it should call disconnectionHandler. 135 | test('should call disconnectionHandler', () => expect(client['__test__disconnectionHandler']).toBeCalledTimes(1)); 136 | 137 | describe('when connect() is called after disconnect', () => { 138 | let promise; 139 | 140 | beforeEach(() => { 141 | jest.spyOn(console, 'warn').mockImplementation(() => {}); 142 | 143 | promise = client.connect(); 144 | }); 145 | 146 | test('should resolve', () => expect(promise).resolves.toBeUndefined()); 147 | test('should warn', () => { 148 | expect(console.warn).toHaveBeenCalledTimes(1); 149 | expect(console.warn).toHaveBeenNthCalledWith(1, expect.stringContaining('connect() can only be called once')); 150 | }); 151 | }); 152 | }); 153 | 154 | describe('when network type change to "bluetooth"', () => { 155 | beforeEach(() => { 156 | connection.type = 'bluetooth'; 157 | }); 158 | 159 | test('should call super.disconnect()', () => expect(client['__test__disconnect']).toBeCalledTimes(1)); 160 | test('should call disconnectionHandler', () => expect(client['__test__disconnectionHandler']).toBeCalledTimes(1)); 161 | }); 162 | 163 | describe('when connect() is called twice', () => { 164 | let promise; 165 | 166 | beforeEach(() => { 167 | jest.spyOn(console, 'warn').mockImplementation(() => {}); 168 | 169 | promise = client.connect(); 170 | }); 171 | 172 | test('should resolve', () => expect(promise).resolves.toBeUndefined()); 173 | test('should warn', () => { 174 | expect(console.warn).toHaveBeenCalledTimes(1); 175 | expect(console.warn).toHaveBeenNthCalledWith(1, expect.stringContaining('connect() can only be called once')); 176 | }); 177 | }); 178 | }); 179 | }); 180 | 181 | describe('initially offline', () => { 182 | test('NetworkInformation should have type of "none"', () => expect(connection).toHaveProperty('type', 'none')); 183 | 184 | describe('when connect() is called', () => { 185 | let promise; 186 | 187 | beforeEach(() => { 188 | promise = client.connect(); 189 | promise.catch(() => {}); 190 | }); 191 | 192 | test('should throw', () => expect(() => promise).rejects.toThrow()); 193 | 194 | // If never connected, it did not need to call disconnectionHandler. 195 | test('should not call super.disconnect()', () => expect(client['__test__disconnect']).toBeCalledTimes(0)); 196 | test('should not call disconnectionHandler', () => 197 | expect(client['__test__disconnectionHandler']).toBeCalledTimes(0)); 198 | }); 199 | }); 200 | -------------------------------------------------------------------------------- /src/streaming/WebSocketClientWithNetworkInformation.test.ts: -------------------------------------------------------------------------------- 1 | import type { WebSocketClient } from 'botframework-streaming'; 2 | import type OriginalWebSocketClientWithNetworkInformation from './WebSocketClientWithNetworkInformation'; 3 | 4 | // Mocked modules are available across the test file. They cannot be unmocked. 5 | // Thus, they are more-or-less similar to import/require. 6 | jest.mock('../../node_modules/botframework-streaming/lib/webSocket/nodeWebSocket', () => ({ 7 | __esmodule: true, 8 | NodeWebSocket: class { 9 | connect() {} 10 | setOnCloseHandler() {} 11 | setOnErrorHandler() {} 12 | setOnMessageHandler() {} 13 | } 14 | })); 15 | 16 | type NetworkInformationType = 'bluetooth' | 'cellular' | 'ethernet' | 'none' | 'other' | 'unknown' | 'wifi' | 'wimax'; 17 | 18 | class MockNetworkInformation extends EventTarget { 19 | constructor() { 20 | super(); 21 | } 22 | 23 | #type: NetworkInformationType = 'none'; 24 | 25 | get type() { 26 | return this.#type; 27 | } 28 | 29 | set type(value: NetworkInformationType) { 30 | if (this.#type !== value) { 31 | this.#type = value; 32 | this.dispatchEvent(new Event('change')); 33 | } 34 | } 35 | } 36 | 37 | type WebSocketClientInit = ConstructorParameters[0]; 38 | 39 | const disconnectionHandler: WebSocketClientInit['disconnectionHandler'] = jest.fn(); 40 | const requestHandler: WebSocketClientInit['requestHandler'] = { processRequest: jest.fn() }; 41 | const url: string = 'wss://dummy/'; 42 | 43 | let client: WebSocketClient; 44 | let networkInformationConnection: MockNetworkInformation; 45 | 46 | beforeEach(() => { 47 | networkInformationConnection = new MockNetworkInformation(); 48 | 49 | let WebSocketClientWithNetworkInformation: typeof OriginalWebSocketClientWithNetworkInformation; 50 | 51 | WebSocketClientWithNetworkInformation = require('./WebSocketClientWithNetworkInformation').default; 52 | 53 | client = new WebSocketClientWithNetworkInformation({ 54 | disconnectionHandler, 55 | networkInformation: networkInformationConnection, 56 | requestHandler, 57 | url 58 | }); 59 | 60 | // Spy on all `console.warn()`. 61 | jest.spyOn(console, 'warn').mockImplementation(() => {}); 62 | }); 63 | 64 | afterEach(() => jest.restoreAllMocks()); 65 | 66 | test('should not call disconnectHandler()', () => expect(disconnectionHandler).toBeCalledTimes(0)); 67 | 68 | describe('initially online', () => { 69 | beforeEach(() => { 70 | networkInformationConnection.type = 'wifi'; 71 | }); 72 | 73 | describe('when connect() is called', () => { 74 | beforeEach(() => client.connect()); 75 | 76 | describe('call disconnect()', () => { 77 | beforeEach(() => client.disconnect()); 78 | 79 | // Both sender/receiver will call `onConnectionDisconnected`, so it is calling it twice. 80 | test('should call disconnectHandler() twice', () => expect(disconnectionHandler).toBeCalledTimes(2)); 81 | 82 | describe('when offline', () => { 83 | beforeEach(() => { 84 | networkInformationConnection.type = 'none'; 85 | }); 86 | 87 | // After disconnected() is called, there should be no extra calls for offline. 88 | test('should have no extra calls to disconnectHandler()', () => 89 | expect(disconnectionHandler).toBeCalledTimes(2)); 90 | }); 91 | }); 92 | 93 | describe('when offline', () => { 94 | beforeEach(() => { 95 | networkInformationConnection.type = 'none'; 96 | }); 97 | 98 | // Both sender/receiver will call `onConnectionDisconnected`, so it is calling it twice. 99 | test('should call disconnectHandler() twice', () => expect(disconnectionHandler).toBeCalledTimes(2)); 100 | 101 | describe('when disconnect() is called', () => { 102 | beforeEach(() => client.disconnect()); 103 | 104 | // After the signal is aborted, there should be no extra calls for calling disconnect(). 105 | test('should have no extra calls to disconnectHandler()', () => 106 | expect(disconnectionHandler).toBeCalledTimes(2)); 107 | }); 108 | }); 109 | }); 110 | }); 111 | -------------------------------------------------------------------------------- /src/streaming/WebSocketClientWithNetworkInformation.ts: -------------------------------------------------------------------------------- 1 | import { WebSocketClient } from 'botframework-streaming'; 2 | 3 | import type { RequestHandler } from 'botframework-streaming'; 4 | 5 | type WebSocketClientWithNetworkInformationInit = { 6 | /** 7 | * Gets or sets the observer function for disconnection or error sending/receiving through WebSocket. 8 | * 9 | * Note: This function could be called multiple times, the callee is expected to ignore subsequent calls. 10 | */ 11 | disconnectionHandler: (message: string) => void; 12 | networkInformation?: NetworkInformation | undefined; 13 | requestHandler: RequestHandler; 14 | url: string; 15 | }; 16 | 17 | export default class WebSocketClientWithNetworkInformation extends WebSocketClient { 18 | constructor({ 19 | disconnectionHandler, 20 | networkInformation, 21 | requestHandler, 22 | url 23 | }: WebSocketClientWithNetworkInformationInit) { 24 | super({ 25 | disconnectionHandler, 26 | requestHandler, 27 | url 28 | }); 29 | 30 | this.#networkInformation = networkInformation; 31 | } 32 | 33 | #connectCalled: boolean = false; 34 | // According to W3C Network Information API, https://wicg.github.io/netinfo/#handling-changes-to-the-underlying-connection. 35 | // NetworkInformation.onChange event will be fired on any changes to: `downlinkMax`, `type`, `downlink`, or `rtt`. 36 | #handleNetworkInformationChange = () => 37 | this.#initialNetworkInformationType === this.#networkInformation.type || this.disconnect(); 38 | #initialNetworkInformationType: NetworkInformation['type']; 39 | #networkInformation: NetworkInformation; 40 | 41 | // TODO: Better, the `NetworkInformation` instance should be passed to `BrowserWebSocketClient` -> `BrowserWebSocket`. 42 | // `BrowserWebSocket` is where it creates `WebSocket` object. 43 | // The `NetworkInformation` instance should accompany `WebSocket` and forcibly close it on abort. 44 | // Maybe `botframework-streaming` should accept ponyfills. 45 | connect(): Promise { 46 | if (this.#connectCalled) { 47 | console.warn('botframework-directlinejs: connect() can only be called once.'); 48 | 49 | return Promise.resolve(); 50 | } 51 | 52 | this.#connectCalled = true; 53 | 54 | if (this.#networkInformation) { 55 | const { type: initialType } = this.#networkInformation; 56 | 57 | this.#initialNetworkInformationType = initialType; 58 | 59 | if (initialType === 'none') { 60 | console.warn('botframework-directlinejs: Failed to connect while offline.'); 61 | 62 | return Promise.reject(new Error('botframework-directlinejs: Failed to connect while offline.')); 63 | } 64 | 65 | this.#networkInformation.addEventListener('change', this.#handleNetworkInformationChange); 66 | } 67 | 68 | return super.connect(); 69 | } 70 | 71 | disconnect() { 72 | this.#networkInformation?.removeEventListener('change', this.#handleNetworkInformationChange); 73 | super.disconnect(); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | // Adopted from https://blogs.msdn.microsoft.com/typescript/2018/08/27/typescript-and-babel-7/ 2 | 3 | { 4 | "compilerOptions": { 5 | // Target latest version of ECMAScript. 6 | "target": "esnext", 7 | 8 | // Search under node_modules for non-relative imports. 9 | "moduleResolution": "node", 10 | 11 | // Enable strictest settings like strictNullChecks & noImplicitAny. 12 | // TODO: [P1] Re-enable strictest when code is clean 13 | // "strict": true, 14 | // Import non-ES modules as default imports. 15 | "esModuleInterop": true, 16 | 17 | "declaration": true, 18 | "declarationDir": "lib", 19 | "emitDeclarationOnly": true, 20 | "sourceMap": true, 21 | 22 | // @types/jsdom@21 is failing. 23 | "types": [ 24 | "express", 25 | "jest", 26 | "jsonwebtoken", 27 | "node", 28 | "p-defer", 29 | "ws" 30 | ] 31 | }, 32 | "exclude": [ 33 | "__tests__/**/*.js", 34 | "__tests__/**/*.ts", 35 | "src/**/*.spec.js", 36 | "src/**/*.spec.ts", 37 | "src/**/*.test.js", 38 | "src/**/*.test.ts" 39 | ], 40 | "include": [ 41 | "src/**/*" 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /webpack-development.config.js: -------------------------------------------------------------------------------- 1 | const webpackConfig = require('./webpack.config'); 2 | 3 | module.exports = { 4 | ...webpackConfig, 5 | mode: 'development' 6 | }; 7 | -------------------------------------------------------------------------------- /webpack-watch.config.js: -------------------------------------------------------------------------------- 1 | const webpackConfig = require('./webpack-development.config'); 2 | 3 | module.exports = { 4 | ...webpackConfig, 5 | stats: { 6 | assets: false, 7 | builtAt: false, 8 | chunks: false, 9 | colors: true, 10 | hash: false, 11 | modules: false, 12 | version: false, 13 | warnings: false 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const { StatsWriterPlugin } = require('webpack-stats-plugin'); 2 | 3 | module.exports = { 4 | entry: { 5 | directline: './lib/directLine.js' 6 | }, 7 | externals: ['net'], 8 | mode: 'production', 9 | module: { 10 | rules: [ 11 | { 12 | // To speed up bundling, we are limiting Babel to a number of packages which does not publish ES5 bits. 13 | test: /\/node_modules\/(botframework-streaming|buffer)\//iu, 14 | use: { 15 | loader: 'babel-loader', 16 | options: { 17 | presets: [ 18 | [ 19 | '@babel/preset-env', 20 | { 21 | modules: 'commonjs' 22 | } 23 | ] 24 | ] 25 | } 26 | } 27 | } 28 | ] 29 | }, 30 | output: { 31 | library: 'DirectLine', 32 | libraryTarget: 'umd' 33 | }, 34 | plugins: [ 35 | new StatsWriterPlugin({ 36 | filename: 'stats.json', 37 | transform: (_, opts) => JSON.stringify(opts.compiler.getStats().toJson({ chunkModules: true }), null, 2) 38 | }) 39 | ], 40 | target: ['web', 'es5'] 41 | }; 42 | --------------------------------------------------------------------------------