├── .editorconfig ├── .gitattributes ├── .github ├── pull_request_template.md ├── renovate.json5 └── workflows │ ├── .java-version │ ├── build.yaml │ ├── gradle-wrapper.yaml │ └── release.yaml ├── .gitignore ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── RELEASING.md ├── api └── Turbine.api ├── build.gradle ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── kotlin-js-store └── yarn.lock ├── settings.gradle └── src ├── commonMain └── kotlin │ └── app │ └── cash │ └── turbine │ ├── Event.kt │ ├── ReceiveTurbine.kt │ ├── Turbine.kt │ ├── TurbineAssertionError.kt │ ├── channel.kt │ ├── coroutines.kt │ └── flow.kt ├── commonTest └── kotlin │ └── app │ └── cash │ └── turbine │ ├── ChannelTest.kt │ ├── CustomThrowable.kt │ ├── FlowInScopeTest.kt │ ├── FlowTest.kt │ ├── RecordingExceptionHandler.kt │ ├── TurbineTest.kt │ └── testUtil.common.kt ├── jvmTest └── kotlin │ └── app │ └── cash │ └── turbine │ ├── ChannelJvmTest.kt │ ├── TurbineJvmTest.kt │ └── testUtil.kt └── nonJvmTest └── kotlin └── app └── cash └── turbine └── testUtil.kt /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | insert_final_newline=true 5 | end_of_line=lf 6 | charset=utf-8 7 | indent_size=2 8 | trim_trailing_whitespace=true 9 | 10 | [*.{kt,kts}] 11 | ij_kotlin_allow_trailing_comma=true 12 | ij_kotlin_allow_trailing_comma_on_call_site=true 13 | ij_kotlin_imports_layout=* 14 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | 3 | *.bat text eol=crlf 4 | *.jar binary -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - [ ] `CHANGELOG.md`'s "Unreleased" section has been updated, if applicable. 4 | -------------------------------------------------------------------------------- /.github/renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | $schema: 'https://docs.renovatebot.com/renovate-schema.json', 3 | extends: [ 4 | 'config:recommended', 5 | ], 6 | ignorePresets: [ 7 | // Ensure we get the latest version and are not pinned to old versions. 8 | 'workarounds:javaLTSVersions', 9 | ], 10 | customManagers: [ 11 | // Update .java-version file with the latest JDK version. 12 | { 13 | customType: 'regex', 14 | fileMatch: [ 15 | '\\.java-version$', 16 | ], 17 | matchStrings: [ 18 | '(?.*)\\n', 19 | ], 20 | datasourceTemplate: 'java-version', 21 | depNameTemplate: 'java', 22 | // Only write the major version. 23 | extractVersionTemplate: '^(?\\d+)', 24 | }, 25 | ], 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/.java-version: -------------------------------------------------------------------------------- 1 | 24 2 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | pull_request: {} 5 | workflow_dispatch: {} 6 | push: 7 | branches: 8 | - 'trunk' 9 | tags-ignore: 10 | - '**' 11 | 12 | env: 13 | GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx4g -Dorg.gradle.daemon=false -Dkotlin.incremental=false" 14 | 15 | jobs: 16 | build: 17 | runs-on: macos-latest 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | - uses: actions/setup-java@v4 22 | with: 23 | distribution: 'zulu' 24 | java-version-file: .github/workflows/.java-version 25 | 26 | - run: ./gradlew build dokkaGenerate 27 | 28 | - run: ./gradlew publish 29 | if: ${{ github.ref == 'refs/heads/trunk' && github.repository == 'cashapp/turbine' }} 30 | env: 31 | ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_USERNAME_APP_CASH }} 32 | ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_PASSWORD_APP_CASH }} 33 | ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.GPG_SECRET_KEY }} 34 | ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.GPG_SECRET_PASSPHRASE }} 35 | 36 | - name: Deploy docs to website 37 | if: ${{ github.ref == 'refs/heads/trunk' && github.repository == 'cashapp/turbine' }} 38 | uses: JamesIves/github-pages-deploy-action@releases/v3 39 | with: 40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 41 | BRANCH: site 42 | FOLDER: build/dokka/html 43 | TARGET_FOLDER: docs/latest/ 44 | CLEAN: true 45 | -------------------------------------------------------------------------------- /.github/workflows/gradle-wrapper.yaml: -------------------------------------------------------------------------------- 1 | name: gradle-wrapper 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - 'gradlew' 7 | - 'gradlew.bat' 8 | - 'gradle/wrapper/**' 9 | 10 | jobs: 11 | validate: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: gradle/actions/wrapper-validation@v4 16 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '**' 7 | 8 | env: 9 | GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx4g -Dorg.gradle.daemon=false -Dkotlin.incremental=false" 10 | 11 | jobs: 12 | publish: 13 | runs-on: macos-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-java@v4 18 | with: 19 | distribution: 'zulu' 20 | java-version-file: .github/workflows/.java-version 21 | 22 | - name: Build and publish artifacts 23 | env: 24 | ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_USERNAME_APP_CASH }} 25 | ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_PASSWORD_APP_CASH }} 26 | ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.GPG_SECRET_KEY }} 27 | ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.GPG_SECRET_PASSPHRASE }} 28 | run: ./gradlew dokkaGenerate publish 29 | 30 | - name: Extract release notes 31 | id: release_notes 32 | uses: ffurrer2/extract-release-notes@v2 33 | 34 | - name: Create release 35 | uses: ncipollo/release-action@v1 36 | with: 37 | body: ${{ steps.release_notes.outputs.release_notes }} 38 | discussionCategory: Announcements 39 | 40 | - name: Deploy docs to website 41 | uses: JamesIves/github-pages-deploy-action@releases/v3 42 | with: 43 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 44 | BRANCH: site 45 | FOLDER: build/dokka/html 46 | TARGET_FOLDER: docs/1.x/ 47 | CLEAN: true 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Gradle 2 | .gradle 3 | build 4 | /reports 5 | 6 | # Kotlin 7 | .kotlin 8 | 9 | # IntelliJ 10 | /.idea 11 | *.iml 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [Unreleased] 4 | [Unreleased]: https://github.com/cashapp/turbine/compare/1.2.0...HEAD 5 | 6 | ### Added 7 | - Nothing yet! 8 | 9 | ### Changed 10 | - Nothing yet! 11 | 12 | ### Fixed 13 | - Calling `testIn` with a `CoroutineScope` that does not contain a `Job` no longer throws `IllegalStateException`. 14 | 15 | 16 | ## [1.2.0] - 2024-10-16 17 | [1.2.0]: https://github.com/cashapp/turbine/releases/tag/1.2.0 18 | 19 | ### Added 20 | - Add `wasmWasi` target. 21 | 22 | 23 | ## [1.1.0] - 2024-03-06 24 | [1.1.0]: https://github.com/cashapp/turbine/releases/tag/1.1.0 25 | 26 | ### Changed 27 | - Add `wasmJs` target, remove `iosArm32` and `watchosX86` targets. 28 | - Throw unconsumed events if scope is externally canceled. 29 | 30 | 31 | ## [1.0.0] - 2023-05-21 32 | ### Added 33 | - Add `turbineScope` DSL function which is now required for using `testIn`. This ensures that exceptions which occur within the test are no longer potentially lost. 34 | 35 | ### Changed 36 | - Failed `Turbine`s which occur while testing a `Flow` (using `test { }`) will now have their failures eagerly reported. 37 | - Build with Kotlin 1.8.22 and kotlinx.coroutines 1.7.1. Note: Future releases will not highlight the Kotlin or coroutines version unless important for some other reason. 38 | 39 | 40 | ## [0.13.0] - 2023-05-11 41 | ### Added 42 | - New Kotlin/Native targets: 43 | - `androidNativeArm32` 44 | - `androidNativeArm64` 45 | - `androidNativeX86` 46 | - `androidNativeX64` 47 | - `linuxArm64` 48 | - `watchosDeviceArm64` 49 | 50 | ### Changed 51 | - Build with Kotlin 1.8.21 52 | 53 | Note: This version has a transitive dependency on kotlinx.coroutines 1.7.0. If you are an Android user, you should be depending on kotlinx.coroutines 1.7.1 which graciously fixes a binary incompatibility with a Compose testing artifact (because they failed to heed its opt-in annotation which indicated that it was not a stable API). 54 | 55 | 56 | ## [0.12.3] 57 | ### Fixed 58 | - Ensure JVM classes target Java 8 rather than implicitly using the build JDK version. 59 | 60 | 61 | ## [0.12.2] 62 | ### Changed 63 | - The default wall-clock timeout used to wait for an event has been increased from 1s to 3s. 64 | 65 | ### Fixed 66 | - The exception thrown when no events have been received now includes the original timeout exception as its cause. This ensures the stacktrace contains the caller file and line information. 67 | - Ensure `expectNoEvents()` fails if a completion or exception event occurred. 68 | - Differentiate timeout and cancellation exceptions thrown from Turbine's own event timeout (which are thrown) from those which occur within the flow under test (which are available via `awaitError()`). 69 | 70 | 71 | ## [0.12.1] 72 | ### Changed 73 | - Build with Kotlin 1.7.20 74 | 75 | ### Fixed 76 | - `takeItem()` no longer throws an exception when returning a `null` value. 77 | - `await`-prefixed methods no longer interfere with virtual time control from a `TestScheduler` (such as inside `runTest`). 78 | 79 | 80 | ## [0.12.0] 81 | ### Added 82 | - Support specifying a human-readable name for differentiating the failures of multiple Turbines 83 | 84 | ### Fixed 85 | - Properly catch all `Throwable` subtypes from failures in flows and channels as events. 86 | 87 | 88 | ## [0.11.0] 89 | ### Added 90 | - Restore timeout support. By default a 1-second timeout will be enforced when awaiting an event. This can be customized by supplying a `timeout` argument or by using the `withTurbineTimeout` wrapper function. Timeouts will always use wall clock time even when using a virtual time dispatcher. 91 | 92 | ### Changed 93 | - When `runTest` (or any `TestCoroutineScheduler`) is in use, switch to the `UnconfinedTestScheduler` internally to ensure virtual time remains working. 94 | 95 | 96 | ## [0.10.0] 97 | ### Changed 98 | - Remove `ReceiveTurbine.ignoreRemainingEvents` from public API. 99 | 100 | ### Fixed 101 | - Restore usage of `Unconfined` dispatcher preventing value conflation (as much as possible) so that intermediate values can always be observed. 102 | 103 | 104 | ## [0.9.0] 105 | - `FlowTurbine` is now called `ReceiveTurbine`. This is the consume-only type with which you assert on events it has seen (historically only from a `Flow`). 106 | - New public `Turbine` type implements `ReceiveTurbine` but also allows you write events from a data source. Use this to implement fakes or collect events from non-`Flow` streams. 107 | - Extension functions on `ReceiveChannel` provide `ReceiveTurbine`-like assertion capabilities. 108 | - Support for legacy JS has been removed. Only JS IR is now supported. 109 | - Removed some APIs deprecated in 0.8.x. 110 | 111 | 112 | ## [0.8.0] 113 | ### Added 114 | - New `testIn` API allows testing multiple flows without nesting lambdas. 115 | - New `skip(Int)` API can replace one or more calls to `awaitItem()` where the result is not needed. 116 | 117 | ### Changed 118 | - Removed timeout parameter. The new `runTest` API from kotlinx.coroutines enforces a timeout automatically. 119 | - Documented that flows are implicitly canceled at the end of the `test` lambda. This has been the behavior for a few versions by accident, but now it is explicit and documented. 120 | - Cancel (and friends) are now suspending functions to ensure that non-canceleable coroutines complete and their effects are observed deterministically. 121 | 122 | 123 | ## [0.7.0] 124 | ### Changed 125 | - Moved APIs using Kotlin's experimental time to separate extensions. You can now use the library 126 | without worrying about incompatibilities with Kotlin version or coroutine library version. 127 | - Removed APIs deprecated in 0.6.x. 128 | 129 | ## [0.6.1] 130 | ### Added 131 | - Support Apple silicon targets for native users. 132 | 133 | ## [0.6.0] 134 | ### Added 135 | - `expectMostRecentItem()` function consumes all received items and returns the most recent item. 136 | 137 | ### Changed 138 | - Functions which may suspend to wait for an event are now prefixed with 'await'. 139 | 140 | ## [0.5.2] 141 | ### Fixed 142 | - Support running on a background thread with Kotlin/Native. 143 | 144 | ## [0.5.1] 145 | ### Added 146 | - Support watchOS 64-bit. 147 | 148 | ## [0.5.0] 149 | ### Changed 150 | - Upgrade to Kotlin 1.5.0. 151 | - Upgrade to kotlinx.coroutines 1.5.0. 152 | 153 | ## [0.5.0-rc1] 154 | ### Changed 155 | - Upgrade to Kotlin 1.5.0. 156 | - Upgrade to kotlinx.coroutines 1.5.0-RC. 157 | 158 | ## [0.4.1] 159 | ### Changed 160 | - Upgrade to kotlinx.coroutines 1.4.3. 161 | - Removed requirement to opt-in to `@ExperimentalCoroutinesApi`. 162 | 163 | ## [0.4.0] 164 | ### Changed 165 | - Upgrade to Kotlin 1.4.30. 166 | 167 | ## [0.3.0] 168 | ### Added 169 | - `cancelAndConsumeRemainingEvents()` cancels the `Flow` and returns any unconsumed events which were already received. 170 | - `expectEvent()` waits for an event (item, complete, or error) and returns it as a sealed type `Event`. 171 | 172 | ## [0.2.1] 173 | ### Added 174 | - Support Javascript IR backend. 175 | 176 | ## [0.2.0] - 2020-08-17 177 | ### Changed 178 | - Upgrade to Kotlin 1.4. 179 | 180 | ## [0.1.1] - 2020-08-03 181 | ### Fixed 182 | - Use the [`Unconfined`](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-dispatchers/-unconfined.html) dispatcher for the internal flow collection coroutine which should eliminate the need to use `yield()` in tests. 183 | 184 | ## [0.1.0] - 2020-08-03 185 | 186 | Initial release 187 | 188 | 189 | [1.0.0]: https://github.com/cashapp/turbine/releases/tag/1.0.0 190 | [0.13.0]: https://github.com/cashapp/turbine/releases/tag/0.13.0 191 | [0.12.3]: https://github.com/cashapp/turbine/releases/tag/0.12.3 192 | [0.12.2]: https://github.com/cashapp/turbine/releases/tag/0.12.2 193 | [0.12.1]: https://github.com/cashapp/turbine/releases/tag/0.12.1 194 | [0.12.0]: https://github.com/cashapp/turbine/releases/tag/0.12.0 195 | [0.11.0]: https://github.com/cashapp/turbine/releases/tag/0.11.0 196 | [0.10.0]: https://github.com/cashapp/turbine/releases/tag/0.10.0 197 | [0.9.0]: https://github.com/cashapp/turbine/releases/tag/0.9.0 198 | [0.8.0]: https://github.com/cashapp/turbine/releases/tag/0.8.0 199 | [0.7.0]: https://github.com/cashapp/turbine/releases/tag/0.7.0 200 | [0.6.1]: https://github.com/cashapp/turbine/releases/tag/0.6.1 201 | [0.6.0]: https://github.com/cashapp/turbine/releases/tag/0.6.0 202 | [0.5.2]: https://github.com/cashapp/turbine/releases/tag/0.5.2 203 | [0.5.1]: https://github.com/cashapp/turbine/releases/tag/0.5.1 204 | [0.5.0]: https://github.com/cashapp/turbine/releases/tag/0.5.0 205 | [0.5.0-rc1]: https://github.com/cashapp/turbine/releases/tag/0.5.0-rc1 206 | [0.4.1]: https://github.com/cashapp/turbine/releases/tag/0.4.1 207 | [0.4.0]: https://github.com/cashapp/turbine/releases/tag/0.4.0 208 | [0.3.0]: https://github.com/cashapp/turbine/releases/tag/0.3.0 209 | [0.2.1]: https://github.com/cashapp/turbine/releases/tag/0.2.1 210 | [0.2.0]: https://github.com/cashapp/turbine/releases/tag/0.2.0 211 | [0.1.1]: https://github.com/cashapp/turbine/releases/tag/0.1.1 212 | [0.1.0]: https://github.com/cashapp/turbine/releases/tag/0.1.0 213 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Turbine 2 | 3 | Turbine is a small testing library for kotlinx.coroutines 4 | [`Flow`](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-flow/). 5 | 6 | ```kotlin 7 | flowOf("one", "two").test { 8 | assertEquals("one", awaitItem()) 9 | assertEquals("two", awaitItem()) 10 | awaitComplete() 11 | } 12 | ``` 13 | 14 | > A turbine is a rotary mechanical device that extracts energy from a fluid flow and converts it into useful work. 15 | > 16 | > – [Wikipedia](https://en.wikipedia.org/wiki/Turbine) 17 | 18 | ## Download 19 | 20 | ```kotlin 21 | repositories { 22 | mavenCentral() 23 | } 24 | dependencies { 25 | testImplementation("app.cash.turbine:turbine:1.2.0") 26 | } 27 | ``` 28 | 29 |
30 | Snapshots of the development version are available in Sonatype's snapshots repository. 31 |

32 | 33 | ```kotlin 34 | repositories { 35 | maven { 36 | url = uri("https://oss.sonatype.org/content/repositories/snapshots/") 37 | } 38 | } 39 | dependencies { 40 | testImplementation("app.cash.turbine:turbine:1.3.0-SNAPSHOT") 41 | } 42 | ``` 43 | 44 |

45 |
46 | 47 | While Turbine's own API is stable, we are currently forced to depend on an unstable API from 48 | kotlinx.coroutines test artifact: `UnconfinedTestDispatcher`. Without this usage of Turbine with 49 | `runTest` would break. It's possible for future coroutine library updates to alter the behavior of 50 | this library as a result. We will make every effort to ensure behavioral stability as well until this 51 | API dependency is stabilized (tracking [issue #132](https://github.com/cashapp/turbine/issues/132)). 52 | 53 | ## Usage 54 | 55 | A `Turbine` is a thin wrapper over a `Channel` with an API designed for testing. 56 | 57 | You can call `awaitItem()` to suspend and wait for an item to be sent to the `Turbine`: 58 | 59 | ```kotlin 60 | assertEquals("one", turbine.awaitItem()) 61 | ``` 62 | 63 | ...`awaitComplete()` to suspend until the `Turbine` completes without an exception: 64 | 65 | ```kotlin 66 | turbine.awaitComplete() 67 | ``` 68 | 69 | ...or `awaitError()` to suspend until the `Turbine` completes with a `Throwable`. 70 | 71 | ```kotlin 72 | assertEquals("broken!", turbine.awaitError().message) 73 | ``` 74 | 75 | If `await*` is called and nothing happens, `Turbine` will timeout and fail instead of hanging. 76 | 77 | When you are done with a `Turbine`, you can clean up by calling `cancel()` to terminate any backing coroutines. 78 | Finally, you can assert that all events were consumed by calling `ensureAllEventsConsumed()`. 79 | 80 | 81 | ### Single Flow 82 | 83 | The simplest way to create and run a `Turbine` is produce one from a `Flow`. 84 | To test a single `Flow`, call the `test` extension: 85 | 86 | ```kotlin 87 | someFlow.test { 88 | // Validation code here! 89 | } 90 | ``` 91 | 92 | `test` launches a new coroutine, calls `someFlow.collect`, and feeds the results into a `Turbine`. 93 | Then it calls the validation block, passing in the read-only `ReceiveTurbine` interface as a receiver: 94 | 95 | ```kotlin 96 | flowOf("one").test { 97 | assertEquals("one", awaitItem()) 98 | awaitComplete() 99 | } 100 | ``` 101 | 102 | When the validation block is complete, `test` cancels the coroutine and calls `ensureAllEventsConsumed()`. 103 | 104 | ### Multiple Flows 105 | 106 | To test multiple flows, assign each `Turbine` to a separate `val` by calling `testIn` instead: 107 | 108 | ```kotlin 109 | runTest { 110 | turbineScope { 111 | val turbine1 = flowOf(1).testIn(backgroundScope) 112 | val turbine2 = flowOf(2).testIn(backgroundScope) 113 | assertEquals(1, turbine1.awaitItem()) 114 | assertEquals(2, turbine2.awaitItem()) 115 | turbine1.awaitComplete() 116 | turbine2.awaitComplete() 117 | } 118 | } 119 | ``` 120 | 121 | Like `test`, `testIn` produces a `ReceiveTurbine`. 122 | `ensureAllEventsConsumed()` will be invoked when the calling coroutine completes. 123 | 124 | `testIn` cannot automatically clean up its coroutine, so it is up to you to ensure that the running flow terminates. 125 | Use `runTest`'s `backgroundScope`, and it will take care of this automatically. 126 | Otherwise, make sure to call one of the following methods before the end of your scope: 127 | 128 | * `cancel()` 129 | * `awaitComplete()` 130 | * `awaitError()` 131 | 132 | Otherwise, your test will hang. 133 | 134 | ### Consuming All Events 135 | 136 | Failing to consume all events before the end of a flow-based `Turbine`'s validation block will fail your test: 137 | 138 | ```kotlin 139 | flowOf("one", "two").test { 140 | assertEquals("one", awaitItem()) 141 | } 142 | ``` 143 | ``` 144 | Exception in thread "main" AssertionError: 145 | Unconsumed events found: 146 | - Item(two) 147 | - Complete 148 | ``` 149 | 150 | The same goes for `testIn`, but at the end of the calling coroutine: 151 | 152 | ```kotlin 153 | runTest { 154 | turbineScope { 155 | val turbine = flowOf("one", "two").testIn(backgroundScope) 156 | turbine.assertEquals("one", awaitItem()) 157 | } 158 | } 159 | ``` 160 | ``` 161 | Exception in thread "main" AssertionError: 162 | Unconsumed events found: 163 | - Item(two) 164 | - Complete 165 | ``` 166 | 167 | Received events can be explicitly ignored, however. 168 | 169 | ```kotlin 170 | flowOf("one", "two").test { 171 | assertEquals("one", awaitItem()) 172 | cancelAndIgnoreRemainingEvents() 173 | } 174 | ``` 175 | 176 | Additionally, we can receive the most recent emitted item and ignore the previous ones. 177 | 178 | ```kotlin 179 | flowOf("one", "two", "three") 180 | .map { 181 | delay(100) 182 | it 183 | } 184 | .test { 185 | // 0 - 100ms -> no emission yet 186 | // 100ms - 200ms -> "one" is emitted 187 | // 200ms - 300ms -> "two" is emitted 188 | // 300ms - 400ms -> "three" is emitted 189 | delay(250) 190 | assertEquals("two", expectMostRecentItem()) 191 | cancelAndIgnoreRemainingEvents() 192 | } 193 | ``` 194 | 195 | 196 | ### Flow Termination 197 | 198 | Flow termination events (exceptions and completion) are exposed as events which must be consumed for validation. 199 | So, for example, throwing a `RuntimeException` inside of your `flow` will not throw an exception in your test. 200 | It will instead produce a Turbine error event: 201 | 202 | ```kotlin 203 | flow { throw RuntimeException("broken!") }.test { 204 | assertEquals("broken!", awaitError().message) 205 | } 206 | ``` 207 | 208 | Failure to consume an error will result in the same unconsumed event exception as above, but 209 | with the exception added as the cause so that the full stacktrace is available. 210 | 211 | ```kotlin 212 | flow { throw RuntimeException("broken!") }.test { } 213 | ``` 214 | ``` 215 | app.cash.turbine.TurbineAssertionError: Unconsumed events found: 216 | - Error(RuntimeException) 217 | at app//app.cash.turbine.ChannelTurbine.ensureAllEventsConsumed(Turbine.kt:215) 218 | ... 80 more 219 | Caused by: java.lang.RuntimeException: broken! 220 | at example.MainKt$main$1.invokeSuspend(FlowTest.kt:652) 221 | ... 105 more 222 | ``` 223 | 224 | ### Standalone Turbines 225 | 226 | In addition to `ReceiveTurbine`s created from flows, standalone `Turbine`s can be used to communicate with test code outside of a flow. 227 | Use them everywhere, and you might never need `runCurrent()` again. 228 | Here's an example of how to use `Turbine()` in a fake: 229 | 230 | ```kotlin 231 | class FakeNavigator : Navigator { 232 | val goTos = Turbine() 233 | 234 | override fun goTo(screen: Screen) { 235 | goTos.add(screen) 236 | } 237 | } 238 | ``` 239 | ```kotlin 240 | runTest { 241 | val navigator = FakeNavigator() 242 | val events: Flow = 243 | MutableSharedFlow(extraBufferCapacity = 50) 244 | val models: Flow = 245 | makePresenter(navigator).present(events) 246 | models.test { 247 | assertEquals(UiModel(title = "Hi there"), awaitItem()) 248 | events.emit(UiEvent.Close) 249 | assertEquals(Screens.Back, navigator.goTos.awaitItem()) 250 | } 251 | } 252 | ``` 253 | 254 | ### Standalone Turbine Compat APIs 255 | 256 | To support codebases with a mix of coroutines and non-coroutines code, standalone `Turbine` includes non-suspending compat APIs. 257 | All the `await` methods have equivalent `take` methods that are non-suspending: 258 | 259 | ```kotlin 260 | val navigator = FakeNavigator() 261 | val events: PublishRelay = PublishRelay.create() 262 | 263 | val models: Observable = 264 | makePresenter(navigator).present(events) 265 | val testObserver = models.test() 266 | testObserver.assertValue(UiModel(title = "Hi there")) 267 | events.accept(UiEvent.Close) 268 | assertEquals(Screens.Back, navigator.goTos.takeItem()) 269 | ``` 270 | 271 | Use `takeItem()` and friends, and `Turbine` behaves like simple queue; use `awaitItem()` and friends, and it's a `Turbine`. 272 | 273 | These methods should only be used from a non-suspending context. 274 | On JVM platforms, they will throw when used from a suspending context. 275 | 276 | ### Asynchronicity and Turbine 277 | 278 | Flows are asynchronous by default. Your flow is collected concurrently by Turbine alongside your test code. 279 | 280 | Handling this asynchronicity works the same way with Turbine as it does in production coroutines code: 281 | instead of using tools like `runCurrent()` to "push" an asynchronous flow along, `Turbine`'s `awaitItem()`, `awaitComplete()`, and `awaitError()` "pull" them along by parking until a new event is ready. 282 | 283 | ```kotlin 284 | channelFlow { 285 | withContext(IO) { 286 | Thread.sleep(100) 287 | send("item") 288 | } 289 | }.test { 290 | assertEquals("item", awaitItem()) 291 | awaitComplete() 292 | } 293 | ``` 294 | 295 | Your validation code may run concurrently with the flow under test, but Turbine puts it in the driver's seat as much as possible: 296 | `test` will end when your validation block is done executing, implicitly cancelling the flow under test. 297 | 298 | ```kotlin 299 | channelFlow { 300 | withContext(IO) { 301 | repeat(10) { 302 | Thread.sleep(200) 303 | send("item $it") 304 | } 305 | } 306 | }.test { 307 | assertEquals("item 0", awaitItem()) 308 | assertEquals("item 1", awaitItem()) 309 | assertEquals("item 2", awaitItem()) 310 | } 311 | ``` 312 | 313 | Flows can also be explicitly canceled at any point. 314 | 315 | ```kotlin 316 | channelFlow { 317 | withContext(IO) { 318 | repeat(10) { 319 | Thread.sleep(200) 320 | send("item $it") 321 | } 322 | } 323 | }.test { 324 | Thread.sleep(700) 325 | cancel() 326 | 327 | assertEquals("item 0", awaitItem()) 328 | assertEquals("item 1", awaitItem()) 329 | assertEquals("item 2", awaitItem()) 330 | } 331 | ``` 332 | 333 | ### Names 334 | 335 | Turbines can be named to improve error feedback. 336 | Pass in a `name` to `test`, `testIn`, or `Turbine()`, and it will be included in any errors that are thrown: 337 | 338 | ```kotlin 339 | runTest { 340 | turbineScope { 341 | val turbine1 = flowOf(1).testIn(backgroundScope, name = "turbine 1") 342 | val turbine2 = flowOf(2).testIn(backgroundScope, name = "turbine 2") 343 | turbine1.awaitComplete() 344 | turbine2.awaitComplete() 345 | } 346 | } 347 | ``` 348 | ``` 349 | Expected complete for turbine 1 but found Item(1) 350 | app.cash.turbine.TurbineAssertionError: Expected complete for turbine 1 but found Item(1) 351 | at app//app.cash.turbine.ChannelKt.unexpectedEvent(channel.kt:258) 352 | at app//app.cash.turbine.ChannelKt.awaitComplete(channel.kt:226) 353 | at app//app.cash.turbine.ChannelKt$awaitComplete$1.invokeSuspend(channel.kt) 354 | at app//kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33) 355 | ... 356 | ``` 357 | 358 | ### Order of Execution & Shared Flows 359 | 360 | Shared flows are sensitive to order of execution. 361 | Calling `emit` before calling `collect` will drop the emitted value: 362 | 363 | ```kotlin 364 | val mutableSharedFlow = MutableSharedFlow(replay = 0) 365 | mutableSharedFlow.emit(1) 366 | mutableSharedFlow.test { 367 | assertEquals(awaitItem(), 1) 368 | } 369 | ``` 370 | ``` 371 | No value produced in 1s 372 | java.lang.AssertionError: No value produced in 1s 373 | at app.cash.turbine.ChannelKt.awaitEvent(channel.kt:90) 374 | at app.cash.turbine.ChannelKt$awaitEvent$1.invokeSuspend(channel.kt) 375 | (Coroutine boundary) 376 | at kotlinx.coroutines.test.TestBuildersKt__TestBuildersKt$runTestCoroutine$2.invokeSuspend(TestBuilders.kt:212) 377 | ``` 378 | 379 | Turbine's `test` and `testIn` methods guarantee that the flow under test will run up to the first suspension point before proceeding. 380 | So calling `test` on a shared flow _before_ emitting will not drop: 381 | 382 | ```kotlin 383 | val mutableSharedFlow = MutableSharedFlow(replay = 0) 384 | mutableSharedFlow.test { 385 | mutableSharedFlow.emit(1) 386 | assertEquals(awaitItem(), 1) 387 | } 388 | ``` 389 | 390 | If your code collects on shared flows, ensure that it does so promptly to have a lovely experience. 391 | 392 | The shared flow types Kotlin currently provides are: 393 | * `MutableStateFlow` 394 | * `StateFlow` 395 | * `MutableSharedFlow` 396 | * `SharedFlow` 397 | 398 | ### Timeouts 399 | 400 | Turbine applies a timeout whenever it waits for an event. 401 | This is a wall clock time timeout that ignores `runTest`'s virtual clock time. 402 | 403 | The default timeout length is three seconds. This can be overridden by passing a timeout duration to `test`: 404 | 405 | ```kotlin 406 | flowOf("one", "two").test(timeout = 10.milliseconds) { 407 | ... 408 | } 409 | ``` 410 | 411 | This timeout will be used for all Turbine-related calls inside the validation block. 412 | 413 | You can also override the timeout for Turbines created with `testIn` and `Turbine()`: 414 | 415 | ```kotlin 416 | val standalone = Turbine(timeout = 10.milliseconds) 417 | val flow = flowOf("one").testIn( 418 | scope = backgroundScope, 419 | timeout = 10.milliseconds, 420 | ) 421 | ``` 422 | 423 | These timeout overrides only apply to the `Turbine` on which they were applied. 424 | 425 | Finally, you can also change the timeout for a whole block of code using `withTurbineTimeout`: 426 | 427 | ```kotlin 428 | withTurbineTimeout(10.milliseconds) { 429 | ... 430 | } 431 | ``` 432 | 433 | ### Channel Extensions 434 | 435 | Most of Turbine's APIs are implemented as extensions on `Channel`. 436 | The more limited API surface of `Turbine` is usually preferable, but these extensions are also available as public APIs if you need them. 437 | 438 | # License 439 | 440 | Copyright 2018 Square, Inc. 441 | 442 | Licensed under the Apache License, Version 2.0 (the "License"); 443 | you may not use this file except in compliance with the License. 444 | You may obtain a copy of the License at 445 | 446 | http://www.apache.org/licenses/LICENSE-2.0 447 | 448 | Unless required by applicable law or agreed to in writing, software 449 | distributed under the License is distributed on an "AS IS" BASIS, 450 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 451 | See the License for the specific language governing permissions and 452 | limitations under the License. 453 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Releasing 2 | 3 | 1. Update the `VERSION_NAME` in `gradle.properties` to the release version. 4 | 5 | 2. Update the `CHANGELOG.md`: 6 | 1. Change the `Unreleased` header to the release version. 7 | 2. Add a link URL to ensure the header link works. 8 | 3. Add a new `Unreleased` section to the top. 9 | 10 | 3. Update the `README.md` so the "Download" section reflects the new release version and the 11 | snapshot section reflects the next "SNAPSHOT" version. 12 | 13 | 4. Commit 14 | 15 | ``` 16 | $ git commit -am "Prepare version X.Y.Z" 17 | ``` 18 | 19 | 5. Tag 20 | 21 | ``` 22 | $ git tag -am "Version X.Y.Z" X.Y.Z 23 | ``` 24 | 25 | 6. Update the `VERSION_NAME` in `gradle.properties` to the next "SNAPSHOT" version. 26 | 27 | 7. Commit 28 | 29 | ``` 30 | $ git commit -am "Prepare next development version" 31 | ``` 32 | 33 | 8. Push! 34 | 35 | ``` 36 | $ git push && git push --tags 37 | ``` 38 | 39 | This will trigger a GitHub Action workflow which will create a GitHub release and upload the 40 | release artifacts to Maven Central. 41 | -------------------------------------------------------------------------------- /api/Turbine.api: -------------------------------------------------------------------------------- 1 | public final class app/cash/turbine/ChannelKt { 2 | public static final fun awaitComplete (Lkotlinx/coroutines/channels/ReceiveChannel;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; 3 | public static synthetic fun awaitComplete$default (Lkotlinx/coroutines/channels/ReceiveChannel;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; 4 | public static final fun awaitError (Lkotlinx/coroutines/channels/ReceiveChannel;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; 5 | public static synthetic fun awaitError$default (Lkotlinx/coroutines/channels/ReceiveChannel;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; 6 | public static final fun awaitEvent (Lkotlinx/coroutines/channels/ReceiveChannel;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; 7 | public static synthetic fun awaitEvent$default (Lkotlinx/coroutines/channels/ReceiveChannel;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; 8 | public static final fun awaitItem (Lkotlinx/coroutines/channels/ReceiveChannel;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; 9 | public static synthetic fun awaitItem$default (Lkotlinx/coroutines/channels/ReceiveChannel;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; 10 | public static final fun expectMostRecentItem (Lkotlinx/coroutines/channels/ReceiveChannel;Ljava/lang/String;)Ljava/lang/Object; 11 | public static synthetic fun expectMostRecentItem$default (Lkotlinx/coroutines/channels/ReceiveChannel;Ljava/lang/String;ILjava/lang/Object;)Ljava/lang/Object; 12 | public static final fun expectNoEvents (Lkotlinx/coroutines/channels/ReceiveChannel;Ljava/lang/String;)V 13 | public static synthetic fun expectNoEvents$default (Lkotlinx/coroutines/channels/ReceiveChannel;Ljava/lang/String;ILjava/lang/Object;)V 14 | public static final fun skipItems (Lkotlinx/coroutines/channels/ReceiveChannel;ILjava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; 15 | public static synthetic fun skipItems$default (Lkotlinx/coroutines/channels/ReceiveChannel;ILjava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; 16 | public static final fun takeComplete (Lkotlinx/coroutines/channels/ReceiveChannel;Ljava/lang/String;)V 17 | public static synthetic fun takeComplete$default (Lkotlinx/coroutines/channels/ReceiveChannel;Ljava/lang/String;ILjava/lang/Object;)V 18 | public static final fun takeError (Lkotlinx/coroutines/channels/ReceiveChannel;Ljava/lang/String;)Ljava/lang/Throwable; 19 | public static synthetic fun takeError$default (Lkotlinx/coroutines/channels/ReceiveChannel;Ljava/lang/String;ILjava/lang/Object;)Ljava/lang/Throwable; 20 | public static final fun takeEvent (Lkotlinx/coroutines/channels/ReceiveChannel;Ljava/lang/String;)Lapp/cash/turbine/Event; 21 | public static synthetic fun takeEvent$default (Lkotlinx/coroutines/channels/ReceiveChannel;Ljava/lang/String;ILjava/lang/Object;)Lapp/cash/turbine/Event; 22 | public static final fun takeItem (Lkotlinx/coroutines/channels/ReceiveChannel;Ljava/lang/String;)Ljava/lang/Object; 23 | public static synthetic fun takeItem$default (Lkotlinx/coroutines/channels/ReceiveChannel;Ljava/lang/String;ILjava/lang/Object;)Ljava/lang/Object; 24 | } 25 | 26 | public final class app/cash/turbine/CoroutinesKt { 27 | public static final fun withTurbineTimeout-KLykuaI (JLkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; 28 | } 29 | 30 | public abstract interface class app/cash/turbine/Event { 31 | public fun isTerminal ()Z 32 | } 33 | 34 | public final class app/cash/turbine/Event$Complete : app/cash/turbine/Event { 35 | public static final field INSTANCE Lapp/cash/turbine/Event$Complete; 36 | public fun toString ()Ljava/lang/String; 37 | } 38 | 39 | public final class app/cash/turbine/Event$Error : app/cash/turbine/Event { 40 | public fun (Ljava/lang/Throwable;)V 41 | public fun equals (Ljava/lang/Object;)Z 42 | public final fun getThrowable ()Ljava/lang/Throwable; 43 | public fun hashCode ()I 44 | public fun toString ()Ljava/lang/String; 45 | } 46 | 47 | public final class app/cash/turbine/Event$Item : app/cash/turbine/Event { 48 | public fun (Ljava/lang/Object;)V 49 | public fun equals (Ljava/lang/Object;)Z 50 | public final fun getValue ()Ljava/lang/Object; 51 | public fun hashCode ()I 52 | public fun toString ()Ljava/lang/String; 53 | } 54 | 55 | public final class app/cash/turbine/FlowKt { 56 | public static final fun test-C2H2yOE (Lkotlinx/coroutines/flow/Flow;Lkotlin/time/Duration;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; 57 | public static synthetic fun test-C2H2yOE$default (Lkotlinx/coroutines/flow/Flow;Lkotlin/time/Duration;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; 58 | public static final fun testIn-5_5nbZA (Lkotlinx/coroutines/flow/Flow;Lkotlinx/coroutines/CoroutineScope;Lkotlin/time/Duration;Ljava/lang/String;)Lapp/cash/turbine/ReceiveTurbine; 59 | public static synthetic fun testIn-5_5nbZA$default (Lkotlinx/coroutines/flow/Flow;Lkotlinx/coroutines/CoroutineScope;Lkotlin/time/Duration;Ljava/lang/String;ILjava/lang/Object;)Lapp/cash/turbine/ReceiveTurbine; 60 | public static final fun turbineScope-k1IrOU0 (Lkotlin/time/Duration;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; 61 | public static synthetic fun turbineScope-k1IrOU0$default (Lkotlin/time/Duration;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; 62 | } 63 | 64 | public abstract interface class app/cash/turbine/ReceiveTurbine { 65 | public abstract fun asChannel ()Lkotlinx/coroutines/channels/ReceiveChannel; 66 | public abstract fun awaitComplete (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; 67 | public abstract fun awaitError (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; 68 | public abstract fun awaitEvent (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; 69 | public abstract fun awaitItem (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; 70 | public abstract fun cancel (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; 71 | public abstract fun cancelAndConsumeRemainingEvents (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; 72 | public abstract fun cancelAndIgnoreRemainingEvents (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; 73 | public abstract fun ensureAllEventsConsumed ()V 74 | public abstract fun expectMostRecentItem ()Ljava/lang/Object; 75 | public abstract fun expectNoEvents ()V 76 | public abstract fun skipItems (ILkotlin/coroutines/Continuation;)Ljava/lang/Object; 77 | } 78 | 79 | public abstract interface class app/cash/turbine/Turbine : app/cash/turbine/ReceiveTurbine { 80 | public abstract fun add (Ljava/lang/Object;)V 81 | public abstract fun asChannel ()Lkotlinx/coroutines/channels/Channel; 82 | public abstract fun close (Ljava/lang/Throwable;)V 83 | public static synthetic fun close$default (Lapp/cash/turbine/Turbine;Ljava/lang/Throwable;ILjava/lang/Object;)V 84 | public abstract fun takeComplete ()V 85 | public abstract fun takeError ()Ljava/lang/Throwable; 86 | public abstract fun takeEvent ()Lapp/cash/turbine/Event; 87 | public abstract fun takeItem ()Ljava/lang/Object; 88 | } 89 | 90 | public abstract interface class app/cash/turbine/TurbineContext : kotlinx/coroutines/CoroutineScope { 91 | public abstract fun testIn-5_5nbZA (Lkotlinx/coroutines/flow/Flow;Lkotlinx/coroutines/CoroutineScope;Lkotlin/time/Duration;Ljava/lang/String;)Lapp/cash/turbine/ReceiveTurbine; 92 | public static synthetic fun testIn-5_5nbZA$default (Lapp/cash/turbine/TurbineContext;Lkotlinx/coroutines/flow/Flow;Lkotlinx/coroutines/CoroutineScope;Lkotlin/time/Duration;Ljava/lang/String;ILjava/lang/Object;)Lapp/cash/turbine/ReceiveTurbine; 93 | } 94 | 95 | public final class app/cash/turbine/TurbineKt { 96 | public static final fun Turbine-dnQKTGw (Lkotlin/time/Duration;Ljava/lang/String;)Lapp/cash/turbine/Turbine; 97 | public static synthetic fun Turbine-dnQKTGw$default (Lkotlin/time/Duration;Ljava/lang/String;ILjava/lang/Object;)Lapp/cash/turbine/Turbine; 98 | public static final fun plusAssign (Lapp/cash/turbine/Turbine;Ljava/lang/Object;)V 99 | } 100 | 101 | public abstract interface class app/cash/turbine/TurbineTestContext : app/cash/turbine/ReceiveTurbine, app/cash/turbine/TurbineContext { 102 | } 103 | 104 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 2 | import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinJvmCompilation 3 | 4 | plugins { 5 | alias(libs.plugins.kotlin) 6 | alias(libs.plugins.kotlinApiDump) 7 | alias(libs.plugins.spotless) 8 | alias(libs.plugins.publish) 9 | alias(libs.plugins.dokka) 10 | } 11 | 12 | kotlin { 13 | explicitApi() 14 | 15 | androidNativeArm32() 16 | androidNativeArm64() 17 | androidNativeX64() 18 | androidNativeX86() 19 | 20 | iosArm64() 21 | iosSimulatorArm64() 22 | iosX64() 23 | 24 | js { 25 | nodejs { 26 | testTask { 27 | useMocha { 28 | timeout = "5s" 29 | } 30 | } 31 | } 32 | } 33 | 34 | jvm { 35 | compilations.configureEach { KotlinJvmCompilation compilation -> 36 | compilation.compilerOptions.options.jvmTarget = JvmTarget.JVM_1_8 37 | compilation.compilerOptions.options.freeCompilerArgs.add("-Xjvm-default=all") 38 | } 39 | } 40 | 41 | linuxArm64() 42 | linuxX64() 43 | 44 | macosArm64() 45 | macosX64() 46 | 47 | mingwX64() 48 | 49 | tvosArm64() 50 | tvosSimulatorArm64() 51 | tvosX64() 52 | 53 | wasmJs().nodejs() 54 | wasmWasi().nodejs() 55 | 56 | watchosArm32() 57 | watchosArm64() 58 | watchosDeviceArm64() 59 | watchosSimulatorArm64() 60 | watchosX64() 61 | 62 | applyDefaultHierarchyTemplate { 63 | it.group("common") { 64 | it.group('nonJvm') { 65 | it.group('native') {} 66 | it.withJs() 67 | it.withWasmJs() 68 | it.withWasmWasi() 69 | } 70 | } 71 | } 72 | 73 | sourceSets { 74 | commonMain { 75 | dependencies { 76 | api libs.coroutines.core 77 | implementation libs.coroutines.test 78 | } 79 | } 80 | commonTest { 81 | dependencies { 82 | implementation 'org.jetbrains.kotlin:kotlin-test' 83 | } 84 | } 85 | } 86 | 87 | sourceSets.matching { it.name.endsWith("Test") }.configureEach { 88 | it.languageSettings { 89 | optIn('kotlinx.coroutines.DelicateCoroutinesApi') 90 | optIn('kotlinx.coroutines.InternalCoroutinesApi') 91 | optIn('kotlinx.coroutines.ExperimentalCoroutinesApi') 92 | } 93 | } 94 | } 95 | 96 | spotless { 97 | kotlin { 98 | target("src/**/*.kt") 99 | ktlint('0.48.2').editorConfigOverride([ 100 | 'ktlint_standard_filename': 'disabled', 101 | ]) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | GROUP=app.cash.turbine 2 | POM_ARTIFACT_ID=turbine 3 | 4 | # HEY! If you change the major version here be sure to update release.yaml doc target folder! 5 | VERSION_NAME=1.3.0-SNAPSHOT 6 | 7 | SONATYPE_AUTOMATIC_RELEASE=true 8 | SONATYPE_HOST=DEFAULT 9 | RELEASE_SIGNING_ENABLED=true 10 | 11 | POM_NAME=Turbine 12 | POM_DESCRIPTION=A small testing library for kotlinx.coroutines Flow. 13 | POM_INCEPTION_YEAR=2018 14 | 15 | POM_URL=https://github.com/cashapp/turbine/ 16 | POM_SCM_URL=https://github.com/cashapp/turbine/ 17 | POM_SCM_CONNECTION=scm:git:git://github.com/cashapp/turbine.git 18 | POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/cashapp/turbine.git 19 | 20 | POM_LICENCE_NAME=The Apache Software License, Version 2.0 21 | POM_LICENCE_URL=https://www.apache.org/licenses/LICENSE-2.0.txt 22 | POM_LICENCE_DIST=repo 23 | 24 | POM_DEVELOPER_ID=cashapp 25 | POM_DEVELOPER_NAME=CashApp 26 | POM_DEVELOPER_URL=https://github.com/cashapp/ 27 | 28 | kotlin.mpp.stability.nowarn=true 29 | 30 | org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled 31 | org.jetbrains.dokka.experimental.gradle.pluginMode.noWarn=true 32 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | coroutines = "1.10.2" 3 | 4 | [libraries] 5 | coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } 6 | coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } 7 | 8 | [plugins] 9 | kotlin = { id = "org.jetbrains.kotlin.multiplatform", version = "2.1.21" } 10 | spotless = { id = "com.diffplug.spotless", version = "7.0.4" } 11 | publish = { id = "com.vanniktech.maven.publish", version = "0.32.0" } 12 | dokka = { id = "org.jetbrains.dokka", version = "2.0.0" } 13 | kotlinApiDump = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version = "0.17.0" } 14 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cashapp/turbine/63b3d0d2846b4dfb48422ba6be1335b1f29d4d6b/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.1-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | # SPDX-License-Identifier: Apache-2.0 19 | # 20 | 21 | ############################################################################## 22 | # 23 | # Gradle start up script for POSIX generated by Gradle. 24 | # 25 | # Important for running: 26 | # 27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 28 | # noncompliant, but you have some other compliant shell such as ksh or 29 | # bash, then to run this script, type that shell name before the whole 30 | # command line, like: 31 | # 32 | # ksh Gradle 33 | # 34 | # Busybox and similar reduced shells will NOT work, because this script 35 | # requires all of these POSIX shell features: 36 | # * functions; 37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 39 | # * compound commands having a testable exit status, especially «case»; 40 | # * various built-in commands including «command», «set», and «ulimit». 41 | # 42 | # Important for patching: 43 | # 44 | # (2) This script targets any POSIX shell, so it avoids extensions provided 45 | # by Bash, Ksh, etc; in particular arrays are avoided. 46 | # 47 | # The "traditional" practice of packing multiple parameters into a 48 | # space-separated string is a well documented source of bugs and security 49 | # problems, so this is (mostly) avoided, by progressively accumulating 50 | # options in "$@", and eventually passing that to Java. 51 | # 52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 54 | # see the in-line comments for details. 55 | # 56 | # There are tweaks for specific operating systems such as AIX, CygWin, 57 | # Darwin, MinGW, and NonStop. 58 | # 59 | # (3) This script is generated from the Groovy template 60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 61 | # within the Gradle project. 62 | # 63 | # You can find Gradle at https://github.com/gradle/gradle/. 64 | # 65 | ############################################################################## 66 | 67 | # Attempt to set APP_HOME 68 | 69 | # Resolve links: $0 may be a link 70 | app_path=$0 71 | 72 | # Need this for daisy-chained symlinks. 73 | while 74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 75 | [ -h "$app_path" ] 76 | do 77 | ls=$( ls -ld "$app_path" ) 78 | link=${ls#*' -> '} 79 | case $link in #( 80 | /*) app_path=$link ;; #( 81 | *) app_path=$APP_HOME$link ;; 82 | esac 83 | done 84 | 85 | # This is normally unused 86 | # shellcheck disable=SC2034 87 | APP_BASE_NAME=${0##*/} 88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH="\\\"\\\"" 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | if ! command -v java >/dev/null 2>&1 137 | then 138 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 139 | 140 | Please set the JAVA_HOME variable in your environment to match the 141 | location of your Java installation." 142 | fi 143 | fi 144 | 145 | # Increase the maximum file descriptors if we can. 146 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 147 | case $MAX_FD in #( 148 | max*) 149 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 150 | # shellcheck disable=SC2039,SC3045 151 | MAX_FD=$( ulimit -H -n ) || 152 | warn "Could not query maximum file descriptor limit" 153 | esac 154 | case $MAX_FD in #( 155 | '' | soft) :;; #( 156 | *) 157 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 158 | # shellcheck disable=SC2039,SC3045 159 | ulimit -n "$MAX_FD" || 160 | warn "Could not set maximum file descriptor limit to $MAX_FD" 161 | esac 162 | fi 163 | 164 | # Collect all arguments for the java command, stacking in reverse order: 165 | # * args from the command line 166 | # * the main class name 167 | # * -classpath 168 | # * -D...appname settings 169 | # * --module-path (only if needed) 170 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 171 | 172 | # For Cygwin or MSYS, switch paths to Windows format before running java 173 | if "$cygwin" || "$msys" ; then 174 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 175 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 176 | 177 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 178 | 179 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 180 | for arg do 181 | if 182 | case $arg in #( 183 | -*) false ;; # don't mess with options #( 184 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 185 | [ -e "$t" ] ;; #( 186 | *) false ;; 187 | esac 188 | then 189 | arg=$( cygpath --path --ignore --mixed "$arg" ) 190 | fi 191 | # Roll the args list around exactly as many times as the number of 192 | # args, so each arg winds up back in the position where it started, but 193 | # possibly modified. 194 | # 195 | # NB: a `for` loop captures its iteration list before it begins, so 196 | # changing the positional parameters here affects neither the number of 197 | # iterations, nor the values presented in `arg`. 198 | shift # remove old arg 199 | set -- "$@" "$arg" # push replacement arg 200 | done 201 | fi 202 | 203 | 204 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 205 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 206 | 207 | # Collect all arguments for the java command: 208 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 209 | # and any embedded shellness will be escaped. 210 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 211 | # treated as '${Hostname}' itself on the command line. 212 | 213 | set -- \ 214 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 215 | -classpath "$CLASSPATH" \ 216 | -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ 217 | "$@" 218 | 219 | # Stop when "xargs" is not available. 220 | if ! command -v xargs >/dev/null 2>&1 221 | then 222 | die "xargs is not available" 223 | fi 224 | 225 | # Use "xargs" to parse quoted args. 226 | # 227 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 228 | # 229 | # In Bash we could simply go: 230 | # 231 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 232 | # set -- "${ARGS[@]}" "$@" 233 | # 234 | # but POSIX shell has neither arrays nor command substitution, so instead we 235 | # post-process each arg (as a line of input to sed) to backslash-escape any 236 | # character that might be a shell metacharacter, then use eval to reverse 237 | # that process (while maintaining the separation between arguments), and wrap 238 | # the whole thing up as a single "set" statement. 239 | # 240 | # This will of course break if any of these variables contains a newline or 241 | # an unmatched quote. 242 | # 243 | 244 | eval "set -- $( 245 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 246 | xargs -n1 | 247 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 248 | tr '\n' ' ' 249 | )" '"$@"' 250 | 251 | exec "$JAVACMD" "$@" 252 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH= 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /kotlin-js-store/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | ansi-colors@^4.1.3: 6 | version "4.1.3" 7 | resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.3.tgz#37611340eb2243e70cc604cad35d63270d48781b" 8 | integrity sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw== 9 | 10 | ansi-regex@^5.0.1: 11 | version "5.0.1" 12 | resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" 13 | integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== 14 | 15 | ansi-styles@^4.0.0, ansi-styles@^4.1.0: 16 | version "4.3.0" 17 | resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" 18 | integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== 19 | dependencies: 20 | color-convert "^2.0.1" 21 | 22 | anymatch@~3.1.2: 23 | version "3.1.2" 24 | resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716" 25 | integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg== 26 | dependencies: 27 | normalize-path "^3.0.0" 28 | picomatch "^2.0.4" 29 | 30 | argparse@^2.0.1: 31 | version "2.0.1" 32 | resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" 33 | integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== 34 | 35 | balanced-match@^1.0.0: 36 | version "1.0.2" 37 | resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" 38 | integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== 39 | 40 | binary-extensions@^2.0.0: 41 | version "2.2.0" 42 | resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" 43 | integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== 44 | 45 | brace-expansion@^2.0.1: 46 | version "2.0.1" 47 | resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" 48 | integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== 49 | dependencies: 50 | balanced-match "^1.0.0" 51 | 52 | braces@~3.0.2: 53 | version "3.0.2" 54 | resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" 55 | integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== 56 | dependencies: 57 | fill-range "^7.0.1" 58 | 59 | browser-stdout@^1.3.1: 60 | version "1.3.1" 61 | resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60" 62 | integrity sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw== 63 | 64 | buffer-from@^1.0.0: 65 | version "1.1.2" 66 | resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" 67 | integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== 68 | 69 | camelcase@^6.0.0: 70 | version "6.3.0" 71 | resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" 72 | integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== 73 | 74 | chalk@^4.1.0: 75 | version "4.1.2" 76 | resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" 77 | integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== 78 | dependencies: 79 | ansi-styles "^4.1.0" 80 | supports-color "^7.1.0" 81 | 82 | chokidar@^3.5.3: 83 | version "3.6.0" 84 | resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" 85 | integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== 86 | dependencies: 87 | anymatch "~3.1.2" 88 | braces "~3.0.2" 89 | glob-parent "~5.1.2" 90 | is-binary-path "~2.1.0" 91 | is-glob "~4.0.1" 92 | normalize-path "~3.0.0" 93 | readdirp "~3.6.0" 94 | optionalDependencies: 95 | fsevents "~2.3.2" 96 | 97 | cliui@^7.0.2: 98 | version "7.0.4" 99 | resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" 100 | integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ== 101 | dependencies: 102 | string-width "^4.2.0" 103 | strip-ansi "^6.0.0" 104 | wrap-ansi "^7.0.0" 105 | 106 | color-convert@^2.0.1: 107 | version "2.0.1" 108 | resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" 109 | integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== 110 | dependencies: 111 | color-name "~1.1.4" 112 | 113 | color-name@~1.1.4: 114 | version "1.1.4" 115 | resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" 116 | integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== 117 | 118 | debug@^4.3.5: 119 | version "4.3.6" 120 | resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.6.tgz#2ab2c38fbaffebf8aa95fdfe6d88438c7a13c52b" 121 | integrity sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg== 122 | dependencies: 123 | ms "2.1.2" 124 | 125 | decamelize@^4.0.0: 126 | version "4.0.0" 127 | resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-4.0.0.tgz#aa472d7bf660eb15f3494efd531cab7f2a709837" 128 | integrity sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ== 129 | 130 | diff@^5.2.0: 131 | version "5.2.0" 132 | resolved "https://registry.yarnpkg.com/diff/-/diff-5.2.0.tgz#26ded047cd1179b78b9537d5ef725503ce1ae531" 133 | integrity sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A== 134 | 135 | emoji-regex@^8.0.0: 136 | version "8.0.0" 137 | resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" 138 | integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== 139 | 140 | escalade@^3.1.1: 141 | version "3.1.1" 142 | resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" 143 | integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== 144 | 145 | escape-string-regexp@^4.0.0: 146 | version "4.0.0" 147 | resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" 148 | integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== 149 | 150 | fill-range@^7.0.1: 151 | version "7.0.1" 152 | resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" 153 | integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ== 154 | dependencies: 155 | to-regex-range "^5.0.1" 156 | 157 | find-up@^5.0.0: 158 | version "5.0.0" 159 | resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" 160 | integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== 161 | dependencies: 162 | locate-path "^6.0.0" 163 | path-exists "^4.0.0" 164 | 165 | flat@^5.0.2: 166 | version "5.0.2" 167 | resolved "https://registry.yarnpkg.com/flat/-/flat-5.0.2.tgz#8ca6fe332069ffa9d324c327198c598259ceb241" 168 | integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ== 169 | 170 | format-util@^1.0.5: 171 | version "1.0.5" 172 | resolved "https://registry.yarnpkg.com/format-util/-/format-util-1.0.5.tgz#1ffb450c8a03e7bccffe40643180918cc297d271" 173 | integrity sha512-varLbTj0e0yVyRpqQhuWV+8hlePAgaoFRhNFj50BNjEIrw1/DphHSObtqwskVCPWNgzwPoQrZAbfa/SBiicNeg== 174 | 175 | fs.realpath@^1.0.0: 176 | version "1.0.0" 177 | resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" 178 | integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= 179 | 180 | fsevents@~2.3.2: 181 | version "2.3.2" 182 | resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" 183 | integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== 184 | 185 | get-caller-file@^2.0.5: 186 | version "2.0.5" 187 | resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" 188 | integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== 189 | 190 | glob-parent@~5.1.2: 191 | version "5.1.2" 192 | resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" 193 | integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== 194 | dependencies: 195 | is-glob "^4.0.1" 196 | 197 | glob@^8.1.0: 198 | version "8.1.0" 199 | resolved "https://registry.yarnpkg.com/glob/-/glob-8.1.0.tgz#d388f656593ef708ee3e34640fdfb99a9fd1c33e" 200 | integrity sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ== 201 | dependencies: 202 | fs.realpath "^1.0.0" 203 | inflight "^1.0.4" 204 | inherits "2" 205 | minimatch "^5.0.1" 206 | once "^1.3.0" 207 | 208 | has-flag@^4.0.0: 209 | version "4.0.0" 210 | resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" 211 | integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== 212 | 213 | he@^1.2.0: 214 | version "1.2.0" 215 | resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" 216 | integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== 217 | 218 | inflight@^1.0.4: 219 | version "1.0.6" 220 | resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" 221 | integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk= 222 | dependencies: 223 | once "^1.3.0" 224 | wrappy "1" 225 | 226 | inherits@2: 227 | version "2.0.4" 228 | resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" 229 | integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== 230 | 231 | is-binary-path@~2.1.0: 232 | version "2.1.0" 233 | resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" 234 | integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== 235 | dependencies: 236 | binary-extensions "^2.0.0" 237 | 238 | is-extglob@^2.1.1: 239 | version "2.1.1" 240 | resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" 241 | integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI= 242 | 243 | is-fullwidth-code-point@^3.0.0: 244 | version "3.0.0" 245 | resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" 246 | integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== 247 | 248 | is-glob@^4.0.1, is-glob@~4.0.1: 249 | version "4.0.3" 250 | resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" 251 | integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== 252 | dependencies: 253 | is-extglob "^2.1.1" 254 | 255 | is-number@^7.0.0: 256 | version "7.0.0" 257 | resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" 258 | integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== 259 | 260 | is-plain-obj@^2.1.0: 261 | version "2.1.0" 262 | resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287" 263 | integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA== 264 | 265 | is-unicode-supported@^0.1.0: 266 | version "0.1.0" 267 | resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" 268 | integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== 269 | 270 | js-yaml@^4.1.0: 271 | version "4.1.0" 272 | resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" 273 | integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== 274 | dependencies: 275 | argparse "^2.0.1" 276 | 277 | kotlin-web-helpers@2.0.0: 278 | version "2.0.0" 279 | resolved "https://registry.yarnpkg.com/kotlin-web-helpers/-/kotlin-web-helpers-2.0.0.tgz#b112096b273c1e733e0b86560998235c09a19286" 280 | integrity sha512-xkVGl60Ygn/zuLkDPx+oHj7jeLR7hCvoNF99nhwXMn8a3ApB4lLiC9pk4ol4NHPjyoCbvQctBqvzUcp8pkqyWw== 281 | dependencies: 282 | format-util "^1.0.5" 283 | 284 | locate-path@^6.0.0: 285 | version "6.0.0" 286 | resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" 287 | integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== 288 | dependencies: 289 | p-locate "^5.0.0" 290 | 291 | log-symbols@^4.1.0: 292 | version "4.1.0" 293 | resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503" 294 | integrity sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg== 295 | dependencies: 296 | chalk "^4.1.0" 297 | is-unicode-supported "^0.1.0" 298 | 299 | minimatch@^5.0.1, minimatch@^5.1.6: 300 | version "5.1.6" 301 | resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96" 302 | integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g== 303 | dependencies: 304 | brace-expansion "^2.0.1" 305 | 306 | mocha@10.7.3: 307 | version "10.7.3" 308 | resolved "https://registry.yarnpkg.com/mocha/-/mocha-10.7.3.tgz#ae32003cabbd52b59aece17846056a68eb4b0752" 309 | integrity sha512-uQWxAu44wwiACGqjbPYmjo7Lg8sFrS3dQe7PP2FQI+woptP4vZXSMcfMyFL/e1yFEeEpV4RtyTpZROOKmxis+A== 310 | dependencies: 311 | ansi-colors "^4.1.3" 312 | browser-stdout "^1.3.1" 313 | chokidar "^3.5.3" 314 | debug "^4.3.5" 315 | diff "^5.2.0" 316 | escape-string-regexp "^4.0.0" 317 | find-up "^5.0.0" 318 | glob "^8.1.0" 319 | he "^1.2.0" 320 | js-yaml "^4.1.0" 321 | log-symbols "^4.1.0" 322 | minimatch "^5.1.6" 323 | ms "^2.1.3" 324 | serialize-javascript "^6.0.2" 325 | strip-json-comments "^3.1.1" 326 | supports-color "^8.1.1" 327 | workerpool "^6.5.1" 328 | yargs "^16.2.0" 329 | yargs-parser "^20.2.9" 330 | yargs-unparser "^2.0.0" 331 | 332 | ms@2.1.2: 333 | version "2.1.2" 334 | resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" 335 | integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== 336 | 337 | ms@^2.1.3: 338 | version "2.1.3" 339 | resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" 340 | integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== 341 | 342 | normalize-path@^3.0.0, normalize-path@~3.0.0: 343 | version "3.0.0" 344 | resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" 345 | integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== 346 | 347 | once@^1.3.0: 348 | version "1.4.0" 349 | resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" 350 | integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= 351 | dependencies: 352 | wrappy "1" 353 | 354 | p-limit@^3.0.2: 355 | version "3.1.0" 356 | resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" 357 | integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== 358 | dependencies: 359 | yocto-queue "^0.1.0" 360 | 361 | p-locate@^5.0.0: 362 | version "5.0.0" 363 | resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" 364 | integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== 365 | dependencies: 366 | p-limit "^3.0.2" 367 | 368 | path-exists@^4.0.0: 369 | version "4.0.0" 370 | resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" 371 | integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== 372 | 373 | picomatch@^2.0.4, picomatch@^2.2.1: 374 | version "2.3.1" 375 | resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" 376 | integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== 377 | 378 | randombytes@^2.1.0: 379 | version "2.1.0" 380 | resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" 381 | integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== 382 | dependencies: 383 | safe-buffer "^5.1.0" 384 | 385 | readdirp@~3.6.0: 386 | version "3.6.0" 387 | resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" 388 | integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== 389 | dependencies: 390 | picomatch "^2.2.1" 391 | 392 | require-directory@^2.1.1: 393 | version "2.1.1" 394 | resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" 395 | integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I= 396 | 397 | safe-buffer@^5.1.0: 398 | version "5.2.1" 399 | resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" 400 | integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== 401 | 402 | serialize-javascript@^6.0.2: 403 | version "6.0.2" 404 | resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2" 405 | integrity sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g== 406 | dependencies: 407 | randombytes "^2.1.0" 408 | 409 | source-map-support@0.5.21: 410 | version "0.5.21" 411 | resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" 412 | integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== 413 | dependencies: 414 | buffer-from "^1.0.0" 415 | source-map "^0.6.0" 416 | 417 | source-map@^0.6.0: 418 | version "0.6.1" 419 | resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" 420 | integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== 421 | 422 | string-width@^4.1.0, string-width@^4.2.0: 423 | version "4.2.3" 424 | resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" 425 | integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== 426 | dependencies: 427 | emoji-regex "^8.0.0" 428 | is-fullwidth-code-point "^3.0.0" 429 | strip-ansi "^6.0.1" 430 | 431 | strip-ansi@^6.0.0, strip-ansi@^6.0.1: 432 | version "6.0.1" 433 | resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" 434 | integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== 435 | dependencies: 436 | ansi-regex "^5.0.1" 437 | 438 | strip-json-comments@^3.1.1: 439 | version "3.1.1" 440 | resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" 441 | integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== 442 | 443 | supports-color@^7.1.0: 444 | version "7.2.0" 445 | resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" 446 | integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== 447 | dependencies: 448 | has-flag "^4.0.0" 449 | 450 | supports-color@^8.1.1: 451 | version "8.1.1" 452 | resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" 453 | integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== 454 | dependencies: 455 | has-flag "^4.0.0" 456 | 457 | to-regex-range@^5.0.1: 458 | version "5.0.1" 459 | resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" 460 | integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== 461 | dependencies: 462 | is-number "^7.0.0" 463 | 464 | typescript@5.5.4: 465 | version "5.5.4" 466 | resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.5.4.tgz#d9852d6c82bad2d2eda4fd74a5762a8f5909e9ba" 467 | integrity sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q== 468 | 469 | workerpool@^6.5.1: 470 | version "6.5.1" 471 | resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.5.1.tgz#060f73b39d0caf97c6db64da004cd01b4c099544" 472 | integrity sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA== 473 | 474 | wrap-ansi@^7.0.0: 475 | version "7.0.0" 476 | resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" 477 | integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== 478 | dependencies: 479 | ansi-styles "^4.0.0" 480 | string-width "^4.1.0" 481 | strip-ansi "^6.0.0" 482 | 483 | wrappy@1: 484 | version "1.0.2" 485 | resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" 486 | integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= 487 | 488 | y18n@^5.0.5: 489 | version "5.0.8" 490 | resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" 491 | integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== 492 | 493 | yargs-parser@^20.2.2, yargs-parser@^20.2.9: 494 | version "20.2.9" 495 | resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" 496 | integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== 497 | 498 | yargs-unparser@^2.0.0: 499 | version "2.0.0" 500 | resolved "https://registry.yarnpkg.com/yargs-unparser/-/yargs-unparser-2.0.0.tgz#f131f9226911ae5d9ad38c432fe809366c2325eb" 501 | integrity sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA== 502 | dependencies: 503 | camelcase "^6.0.0" 504 | decamelize "^4.0.0" 505 | flat "^5.0.2" 506 | is-plain-obj "^2.1.0" 507 | 508 | yargs@^16.2.0: 509 | version "16.2.0" 510 | resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66" 511 | integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw== 512 | dependencies: 513 | cliui "^7.0.2" 514 | escalade "^3.1.1" 515 | get-caller-file "^2.0.5" 516 | require-directory "^2.1.1" 517 | string-width "^4.2.0" 518 | y18n "^5.0.5" 519 | yargs-parser "^20.2.2" 520 | 521 | yocto-queue@^0.1.0: 522 | version "0.1.0" 523 | resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" 524 | integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== 525 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | mavenCentral() 4 | // https://github.com/diffplug/spotless/issues/1153 5 | gradlePluginPortal { 6 | content { 7 | includeModule("com.diffplug.spotless", "com.diffplug.spotless.gradle.plugin") 8 | } 9 | } 10 | } 11 | } 12 | 13 | dependencyResolutionManagement { 14 | repositories { 15 | mavenCentral() 16 | } 17 | } 18 | 19 | rootProject.name = "Turbine" 20 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/app/cash/turbine/Event.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2022 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.turbine 17 | 18 | public sealed interface Event { 19 | public object Complete : Event { 20 | override fun toString(): String = "Complete" 21 | } 22 | public class Error(public val throwable: Throwable) : Event { 23 | override fun equals(other: Any?): Boolean = other is Error && throwable == other.throwable 24 | override fun hashCode(): Int = throwable.hashCode() 25 | override fun toString(): String = "Error(${throwable::class.simpleName})" 26 | } 27 | public class Item(public val value: T) : Event { 28 | override fun equals(other: Any?): Boolean = other is Item<*> && value == other.value 29 | override fun hashCode(): Int = value.hashCode() 30 | override fun toString(): String = "Item($value)" 31 | } 32 | 33 | public val isTerminal: Boolean 34 | get() = this is Complete || this is Error 35 | } 36 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/app/cash/turbine/ReceiveTurbine.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2022 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.turbine 17 | 18 | import kotlinx.coroutines.channels.ReceiveChannel 19 | 20 | public interface ReceiveTurbine { 21 | /** 22 | * Returns the underlying [ReceiveChannel]. 23 | */ 24 | public fun asChannel(): ReceiveChannel 25 | 26 | /** 27 | * Cancel this [ReceiveTurbine]. 28 | * 29 | * If it is backed by an underlying coroutine (e.g. the coroutine run 30 | * by [test]), that coroutine will also be cancelled. 31 | * 32 | * If [cancel] is invoked before the underlying coroutine or channel has been closed, [ensureAllEventsConsumed] 33 | * will succeed even if the terminal event is not consumed. 34 | */ 35 | public suspend fun cancel() 36 | 37 | /** 38 | * Cancel this [ReceiveTurbine] and ignore any events which have already been received. 39 | * 40 | * If it is backed by an underlying coroutine (e.g. the coroutine run 41 | * by [test]), that coroutine will also be cancelled. If called within a [test] block, the [test] block 42 | * will exit. 43 | */ 44 | public suspend fun cancelAndIgnoreRemainingEvents() 45 | 46 | /** 47 | * Cancel this [ReceiveTurbine] and return any events which have already been received. 48 | * 49 | * If it is backed by an underlying coroutine (e.g. the coroutine run 50 | * by [test]), that coroutine will also be cancelled. If called within a [test] block, the [test] block 51 | * will exit. 52 | */ 53 | public suspend fun cancelAndConsumeRemainingEvents(): List> 54 | 55 | /** 56 | * Assert that there are no unconsumed events which have already been received. 57 | * 58 | * @throws AssertionError if unconsumed events are found. 59 | */ 60 | public fun expectNoEvents() 61 | 62 | /** 63 | * Returns the most recent item that has already been received. 64 | * If a complete event has been received with no item being received 65 | * previously, this function will throw an [AssertionError]. If an error event 66 | * has been received, this function will throw the underlying exception. 67 | * 68 | * @throws AssertionError if no item was emitted. 69 | */ 70 | public fun expectMostRecentItem(): T 71 | 72 | /** 73 | * Assert that an event was received and return it. 74 | * This function will suspend if no events have been received. 75 | * 76 | * When this [ReceiveTurbine] is in a terminal state ([Event.Complete] or [Event.Error]), this method 77 | * will yield the same result every time it is called. 78 | */ 79 | public suspend fun awaitEvent(): Event 80 | 81 | /** 82 | * Assert that the next event received was an item and return it. 83 | * This function will suspend if no events have been received. 84 | * 85 | * When this [ReceiveTurbine] is in a terminal state ([Event.Complete] or [Event.Error]), this method 86 | * will yield the same result every time it is called. 87 | * 88 | * @throws AssertionError if the next event was completion or an error. 89 | */ 90 | public suspend fun awaitItem(): T 91 | 92 | /** 93 | * Assert that [count] item events were received and ignore them. 94 | * This function will suspend if no events have been received. 95 | * 96 | * @throws AssertionError if one of the events was completion or an error. 97 | */ 98 | public suspend fun skipItems(count: Int) 99 | 100 | /** 101 | * Assert that the next event received was the flow completing. 102 | * This function will suspend if no events have been received. 103 | * 104 | * When this [ReceiveTurbine] is in a terminal state ([Event.Complete] or [Event.Error]), this method 105 | * will yield the same result every time it is called. 106 | * 107 | * @throws AssertionError if the next event was an item or an error. 108 | */ 109 | public suspend fun awaitComplete() 110 | 111 | /** 112 | * Assert that the next event received was an error terminating the flow. 113 | * This function will suspend if no events have been received. 114 | * 115 | * When this [ReceiveTurbine] is in a terminal state ([Event.Complete] or [Event.Error]), this method 116 | * will yield the same result every time it is called. 117 | * 118 | * @throws AssertionError if the next event was an item or completion. 119 | */ 120 | public suspend fun awaitError(): Throwable 121 | 122 | /** 123 | * Assert that all events that have occurred so far on this [ReceiveTurbine] have been consumed. 124 | * 125 | * [ensureAllEventsConsumed] will execute the same assertion performed internally by [test] and [testIn] 126 | * on the completion of the validation block. 127 | */ 128 | public fun ensureAllEventsConsumed() 129 | } 130 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/app/cash/turbine/Turbine.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2022 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.turbine 17 | 18 | import kotlin.time.Duration 19 | import kotlinx.coroutines.CancellationException 20 | import kotlinx.coroutines.DelicateCoroutinesApi 21 | import kotlinx.coroutines.Job 22 | import kotlinx.coroutines.cancelAndJoin 23 | import kotlinx.coroutines.channels.Channel 24 | import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED 25 | import kotlinx.coroutines.channels.ChannelResult 26 | 27 | internal const val debug = false 28 | 29 | /** 30 | * A standalone [Turbine] suitable for usage in fakes or other external test components. 31 | */ 32 | public interface Turbine : ReceiveTurbine { 33 | /** 34 | * Returns the underlying [Channel]. The [Channel] will have a buffer size of [UNLIMITED]. 35 | */ 36 | public override fun asChannel(): Channel 37 | 38 | /** 39 | * Closes the underlying [Channel]. After all items have been consumed, this [Turbine] will yield 40 | * [Event.Complete] if [cause] is null, and [Event.Error] otherwise. 41 | */ 42 | public fun close(cause: Throwable? = null) 43 | 44 | /** 45 | * Add an item to the underlying [Channel] without blocking. 46 | * 47 | * This method is equivalent to: 48 | * 49 | * ``` 50 | * if (!asChannel().trySend(item).isSuccess) error() 51 | * ``` 52 | */ 53 | public fun add(item: T) 54 | 55 | /** 56 | * Assert that the next event received was non-null and return it. 57 | * This function will not suspend. On JVM and Android, it will attempt to throw if invoked in a suspending context. 58 | * 59 | * @throws AssertionError if the next event was completion or an error. 60 | */ 61 | public fun takeEvent(): Event 62 | 63 | /** 64 | * Assert that the next event received was an item and return it. 65 | * This function will not suspend. On JVM and Android, it will attempt to throw if invoked in a suspending context. 66 | * 67 | * @throws AssertionError if the next event was completion or an error, or no event. 68 | */ 69 | public fun takeItem(): T 70 | 71 | /** 72 | * Assert that the next event received is [Event.Complete]. 73 | * This function will not suspend. On JVM and Android, it will attempt to throw if invoked in a suspending context. 74 | * 75 | * @throws AssertionError if the next event was completion or an error. 76 | */ 77 | public fun takeComplete() 78 | 79 | /** 80 | * Assert that the next event received is [Event.Error], and return the error. 81 | * This function will not suspend. On JVM and Android, it will attempt to throw if invoked in a suspending context. 82 | * 83 | * @throws AssertionError if the next event was completion or an error. 84 | */ 85 | public fun takeError(): Throwable 86 | } 87 | 88 | public operator fun Turbine.plusAssign(value: T) { add(value) } 89 | 90 | /** 91 | * Construct a standalone [Turbine]. 92 | * 93 | * @param timeout If non-null, overrides the current Turbine timeout for this [Turbine]. See also: 94 | * [withTurbineTimeout]. 95 | * @param name If non-null, name is added to any exceptions thrown to help identify which [Turbine] failed. 96 | */ 97 | public fun Turbine( 98 | timeout: Duration? = null, 99 | name: String? = null, 100 | ): Turbine = ChannelTurbine(Channel(UNLIMITED), null, timeout, name) 101 | 102 | internal class ChannelTurbine( 103 | channel: Channel, 104 | /** Non-null if [channel] is being populated by an external `Flow` collection. */ 105 | private val collectJob: Job?, 106 | private val timeout: Duration?, 107 | private val name: String?, 108 | ) : Turbine { 109 | private suspend fun withTurbineTimeout(block: suspend () -> T): T { 110 | return if (timeout != null) { 111 | withTurbineTimeout(timeout) { block() } 112 | } else { 113 | block() 114 | } 115 | } 116 | 117 | private val channel = object : Channel by channel { 118 | override fun tryReceive(): ChannelResult { 119 | val result = channel.tryReceive() 120 | val event = result.toEvent() 121 | if (event is Event.Error || event is Event.Complete) ignoreRemainingEvents = true 122 | 123 | return result 124 | } 125 | 126 | override suspend fun receive(): T = try { 127 | channel.receive() 128 | } catch (e: Throwable) { 129 | ignoreRemainingEvents = true 130 | throw e 131 | } 132 | 133 | override suspend fun receiveCatching(): ChannelResult { 134 | return channel.receiveCatching().also { 135 | if (it.toEvent()?.isTerminal == true) { 136 | ignoreRemainingEvents = true 137 | } 138 | } 139 | } 140 | 141 | override fun cancel(cause: CancellationException?) { 142 | collectJob?.cancel() 143 | channel.close(cause) 144 | } 145 | 146 | override fun close(cause: Throwable?): Boolean { 147 | collectJob?.cancel() 148 | return channel.close(cause) 149 | } 150 | } 151 | 152 | override fun asChannel(): Channel = channel 153 | 154 | override fun add(item: T) { 155 | if (!channel.trySend(item).isSuccess) throw IllegalStateException("Attempt to add item to a closed Turbine${name?.let { " named $it" } ?: ""}.") 156 | } 157 | 158 | @OptIn(DelicateCoroutinesApi::class) 159 | override suspend fun cancel() { 160 | if (!channel.isClosedForSend) ignoreTerminalEvents = true 161 | channel.cancel() 162 | collectJob?.cancelAndJoin() 163 | } 164 | 165 | @OptIn(DelicateCoroutinesApi::class) 166 | override fun close(cause: Throwable?) { 167 | if (!channel.isClosedForSend) ignoreTerminalEvents = true 168 | channel.close(cause) 169 | collectJob?.cancel() 170 | } 171 | 172 | override fun takeEvent(): Event = channel.takeEvent(name = name) 173 | 174 | override fun takeItem(): T = channel.takeItem(name = name) 175 | 176 | override fun takeComplete() = channel.takeComplete(name = name) 177 | 178 | override fun takeError(): Throwable = channel.takeError(name = name) 179 | 180 | private var ignoreTerminalEvents = false 181 | private var ignoreRemainingEvents = false 182 | 183 | override suspend fun cancelAndIgnoreRemainingEvents() { 184 | cancel() 185 | ignoreRemainingEvents = true 186 | } 187 | 188 | override suspend fun cancelAndConsumeRemainingEvents(): List> { 189 | val events = buildList { 190 | while (true) { 191 | val event = channel.takeEventUnsafe() ?: break 192 | add(event) 193 | if (event is Event.Error || event is Event.Complete) break 194 | } 195 | } 196 | ignoreRemainingEvents = true 197 | cancel() 198 | 199 | return events 200 | } 201 | 202 | override fun expectNoEvents() { 203 | channel.expectNoEvents(name = name) 204 | } 205 | 206 | override fun expectMostRecentItem(): T = channel.expectMostRecentItem(name = name) 207 | 208 | override suspend fun awaitEvent(): Event = withTurbineTimeout { channel.awaitEvent(name = name) } 209 | 210 | override suspend fun awaitItem(): T = withTurbineTimeout { channel.awaitItem(name = name) } 211 | 212 | override suspend fun skipItems(count: Int) = withTurbineTimeout { channel.skipItems(count, name) } 213 | 214 | override suspend fun awaitComplete() = withTurbineTimeout { channel.awaitComplete(name = name) } 215 | 216 | override suspend fun awaitError(): Throwable = withTurbineTimeout { channel.awaitError(name = name) } 217 | 218 | internal fun reportUnconsumedEvents(): UnconsumedEventReport { 219 | if (ignoreRemainingEvents) return UnconsumedEventReport(emptyList()) 220 | 221 | val unconsumed = mutableListOf>() 222 | var cause: Throwable? = null 223 | while (true) { 224 | val event = channel.takeEventUnsafe() ?: break 225 | if (event is Event.Error && event.throwable is CancellationException) break 226 | if (!(ignoreTerminalEvents && event.isTerminal)) unconsumed += event 227 | if (event is Event.Error) { 228 | cause = event.throwable 229 | break 230 | } else if (event is Event.Complete) { 231 | break 232 | } 233 | } 234 | 235 | return UnconsumedEventReport( 236 | name = name, 237 | unconsumed = unconsumed, 238 | cause = cause, 239 | ) 240 | } 241 | 242 | override fun ensureAllEventsConsumed() { 243 | val report = reportUnconsumedEvents() 244 | 245 | if (report.unconsumed.isNotEmpty()) { 246 | throw TurbineAssertionError( 247 | buildString { 248 | report.describe(this) 249 | }, 250 | report.cause, 251 | ) 252 | } 253 | } 254 | } 255 | 256 | internal class UnconsumedEventReport( 257 | val unconsumed: List>, 258 | val name: String? = null, 259 | val cause: Throwable? = null, 260 | ) { 261 | fun describe(builder: StringBuilder) { 262 | with(builder) { 263 | append("Unconsumed events found".qualifiedBy(name)) 264 | append(":") 265 | for (event in unconsumed) { 266 | append("\n - $event") 267 | } 268 | } 269 | } 270 | 271 | fun describeException(builder: StringBuilder) { 272 | with(builder) { 273 | cause?.let { cause -> 274 | append("Unconsumed exception found".qualifiedBy(name)) 275 | append(":") 276 | appendLine( 277 | """ 278 | | 279 | | 280 | |Stack trace: 281 | """.trimMargin(), 282 | ) 283 | append(cause.stackTraceToString()) 284 | appendLine() 285 | } 286 | } 287 | } 288 | 289 | fun stripCancellations(): UnconsumedEventReport = 290 | UnconsumedEventReport( 291 | unconsumed = unconsumed.filter { 292 | (it as? Event.Error)?.throwable !is CancellationException 293 | }, 294 | name = name, 295 | cause = cause?.takeUnless { it is CancellationException }, 296 | ) 297 | } 298 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/app/cash/turbine/TurbineAssertionError.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2022 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.turbine 17 | 18 | /** 19 | * A custom [AssertionError] to work around the fact that exceptions with public constructors 20 | * have referential equality broken by coroutines. 21 | * 22 | * See https://github.com/Kotlin/kotlinx.coroutines/blob/5b64a1fcf36cbea6fbe3cf70966f4907a2a5f92f/docs/topics/debugging.md#stacktrace-recovery-machinery 23 | * 24 | * TODO Migrate to implementing `CopyThrowable` and returning `null` from `createCopy` once it is stable. 25 | * https://github.com/Kotlin/kotlinx.coroutines/issues/2367 26 | */ 27 | internal class TurbineAssertionError private constructor( 28 | message: String, 29 | cause: Throwable?, 30 | ) : AssertionError(message, cause) { 31 | companion object { 32 | operator fun invoke(message: String, cause: Throwable?) = TurbineAssertionError(message, cause) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/app/cash/turbine/channel.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2022 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.turbine 17 | 18 | import kotlin.coroutines.coroutineContext 19 | import kotlin.time.Duration 20 | import kotlinx.coroutines.CancellationException 21 | import kotlinx.coroutines.CoroutineScope 22 | import kotlinx.coroutines.CoroutineStart 23 | import kotlinx.coroutines.DelicateCoroutinesApi 24 | import kotlinx.coroutines.Dispatchers 25 | import kotlinx.coroutines.GlobalScope 26 | import kotlinx.coroutines.TimeoutCancellationException 27 | import kotlinx.coroutines.async 28 | import kotlinx.coroutines.channels.ChannelResult 29 | import kotlinx.coroutines.channels.ClosedReceiveChannelException 30 | import kotlinx.coroutines.channels.ReceiveChannel 31 | import kotlinx.coroutines.coroutineScope 32 | import kotlinx.coroutines.delay 33 | import kotlinx.coroutines.launch 34 | import kotlinx.coroutines.selects.select 35 | import kotlinx.coroutines.test.TestCoroutineScheduler 36 | import kotlinx.coroutines.withTimeout 37 | 38 | /** 39 | * Returns the most recent item that has already been received. 40 | * If channel was closed with no item being received 41 | * previously, this function will throw an [AssertionError]. If channel 42 | * was closed with an exception, this function will throw the underlying exception. 43 | * 44 | * @throws AssertionError if no item was emitted. 45 | */ 46 | public fun ReceiveChannel.expectMostRecentItem(name: String? = null): T { 47 | var previous: ChannelResult? = null 48 | while (true) { 49 | val current = tryReceive() 50 | current.exceptionOrNull()?.let { throw it } 51 | if (current.isFailure) { 52 | break 53 | } 54 | previous = current 55 | } 56 | 57 | if (previous?.isSuccess == true) return previous.getOrThrow() 58 | 59 | throw AssertionError("No item was found".qualifiedBy(name)) 60 | } 61 | 62 | /** 63 | * Assert that there are no unconsumed events which have already been received. 64 | * 65 | * A channel in the closed state will always emit either [Event.Complete] or [Event.Error] when read, so 66 | * [expectNoEvents] will only succeed on an empty [ReceiveChannel] that is not closed. 67 | * 68 | * @throws AssertionError if unconsumed events are found. 69 | */ 70 | public fun ReceiveChannel.expectNoEvents(name: String? = null) { 71 | tryReceive().toEvent()?.let { unexpectedEvent(name, it, "no events") } 72 | } 73 | 74 | /** 75 | * Assert that an event was received and return it. 76 | * This function will suspend if no events have been received. 77 | * 78 | * This function will always return a terminal event on a closed [ReceiveChannel]. 79 | */ 80 | public suspend fun ReceiveChannel.awaitEvent(name: String? = null): Event { 81 | val timeout = contextTimeout() 82 | return try { 83 | withAppropriateTimeout(timeout) { 84 | receiveCatching().toEvent()!! 85 | } 86 | } catch (e: TimeoutCancellationException) { 87 | throw TurbineAssertionError("No ${"value produced".qualifiedBy(name)} in $timeout", e) 88 | } catch (e: TurbineTimeoutCancellationException) { 89 | throw TurbineAssertionError("No ${"value produced".qualifiedBy(name)} in $timeout", e) 90 | } 91 | } 92 | 93 | private suspend fun withAppropriateTimeout( 94 | timeout: Duration, 95 | block: suspend CoroutineScope.() -> T, 96 | ): T { 97 | return if (coroutineContext[TestCoroutineScheduler] != null) { 98 | // withTimeout uses virtual time, which will hang. 99 | withWallclockTimeout(timeout, block) 100 | } else { 101 | withTimeout(timeout, block) 102 | } 103 | } 104 | 105 | private suspend fun withWallclockTimeout( 106 | timeout: Duration, 107 | block: suspend CoroutineScope.() -> T, 108 | ): T = coroutineScope { 109 | val blockDeferred = async(start = CoroutineStart.UNDISPATCHED, block = block) 110 | 111 | // Run the timeout on a scope separate from the caller. This ensures that the use of the 112 | // Default dispatcher does not affect the use of a TestScheduler and its fake time. 113 | @OptIn(DelicateCoroutinesApi::class) 114 | val timeoutJob = GlobalScope.launch(Dispatchers.Default) { delay(timeout) } 115 | 116 | select { 117 | blockDeferred.onAwait { result -> 118 | timeoutJob.cancel() 119 | result 120 | } 121 | timeoutJob.onJoin { 122 | blockDeferred.cancel() 123 | throw TurbineTimeoutCancellationException("Timed out waiting for $timeout") 124 | } 125 | } 126 | } 127 | 128 | internal class TurbineTimeoutCancellationException internal constructor( 129 | message: String, 130 | ) : CancellationException(message) 131 | 132 | /** 133 | * Assert that the next event received was non-null and return it. 134 | * This function will not suspend. On JVM and Android, it will attempt to throw if invoked in a suspending context. 135 | * 136 | * @throws AssertionError if the next event was completion or an error. 137 | */ 138 | public fun ReceiveChannel.takeEvent(name: String? = null): Event { 139 | assertCallingContextIsNotSuspended() 140 | return takeEventUnsafe() 141 | ?: unexpectedEvent(name, null, "an event") 142 | } 143 | 144 | internal fun ReceiveChannel.takeEventUnsafe(): Event? { 145 | return tryReceive().toEvent() 146 | } 147 | 148 | /** 149 | * Assert that the next event received was an item and return it. 150 | * This function will not suspend. On JVM and Android, it will attempt to throw if invoked in a suspending context. 151 | * 152 | * @throws AssertionError if the next event was completion or an error, or no event. 153 | */ 154 | public fun ReceiveChannel.takeItem(name: String? = null): T { 155 | return when (val event = takeEvent()) { 156 | is Event.Item -> event.value 157 | else -> unexpectedEvent(name, event, "item") 158 | } 159 | } 160 | 161 | /** 162 | * Assert that the next event received is [Event.Complete]. 163 | * This function will not suspend. On JVM and Android, it will attempt to throw if invoked in a suspending context. 164 | * 165 | * @throws AssertionError if the next event was completion or an error. 166 | */ 167 | public fun ReceiveChannel.takeComplete(name: String? = null) { 168 | val event = takeEvent() 169 | if (event !is Event.Complete) unexpectedEvent(name, event, "complete") 170 | } 171 | 172 | /** 173 | * Assert that the next event received is [Event.Error], and return the error. 174 | * This function will not suspend. On JVM and Android, it will attempt to throw if invoked in a suspending context. 175 | * 176 | * @throws AssertionError if the next event was completion or an error. 177 | */ 178 | public fun ReceiveChannel.takeError(name: String? = null): Throwable { 179 | val event = takeEvent() 180 | return (event as? Event.Error)?.throwable ?: unexpectedEvent(name, event, "error") 181 | } 182 | 183 | /** 184 | * Assert that the next event received was an item and return it. 185 | * This function will suspend if no events have been received. 186 | * 187 | * @throws AssertionError if the next event was completion or an error. 188 | */ 189 | public suspend fun ReceiveChannel.awaitItem(name: String? = null): T = 190 | when (val result = awaitEvent(name = name)) { 191 | is Event.Item -> result.value 192 | else -> unexpectedEvent(name, result, "item") 193 | } 194 | 195 | /** 196 | * Assert that [count] item events were received and ignore them. 197 | * This function will suspend if no events have been received. 198 | * 199 | * @throws AssertionError if one of the events was completion or an error. 200 | */ 201 | public suspend fun ReceiveChannel.skipItems(count: Int, name: String? = null) { 202 | repeat(count) { index -> 203 | when (val event = awaitEvent()) { 204 | Event.Complete, is Event.Error -> { 205 | val cause = (event as? Event.Error)?.throwable 206 | throw TurbineAssertionError("Expected $count ${"items".qualifiedBy(name)} but got $index items and $event", cause) 207 | } 208 | is Event.Item -> { 209 | // Success 210 | } 211 | } 212 | } 213 | } 214 | 215 | /** 216 | * Assert that attempting to read from the [ReceiveChannel] yields [ClosedReceiveChannelException], indicating 217 | * that it was closed without an exception. 218 | * 219 | * @throws AssertionError if the next event was an item or an error. 220 | */ 221 | public suspend fun ReceiveChannel.awaitComplete(name: String? = null) { 222 | val event = awaitEvent() 223 | if (event != Event.Complete) { 224 | unexpectedEvent(name, event, "complete") 225 | } 226 | } 227 | 228 | /** 229 | * Assert that attempting to read from the [ReceiveChannel] yields an exception, indicating 230 | * that it was closed with an exception. 231 | * 232 | * @throws AssertionError if the next event was an item or completion. 233 | */ 234 | public suspend fun ReceiveChannel.awaitError(name: String? = null): Throwable { 235 | val event = awaitEvent() 236 | return (event as? Event.Error)?.throwable 237 | ?: unexpectedEvent(name, event, "error") 238 | } 239 | 240 | internal fun ChannelResult.toEvent(): Event? { 241 | val cause = exceptionOrNull() 242 | return when { 243 | isSuccess -> Event.Item(getOrThrow()) 244 | cause != null -> Event.Error(cause) 245 | isClosed -> Event.Complete 246 | else -> null 247 | } 248 | } 249 | 250 | private fun unexpectedEvent(name: String?, event: Event<*>?, expected: String): Nothing { 251 | val cause = (event as? Event.Error)?.throwable 252 | val eventAsString = event?.toString() ?: "no items" 253 | throw TurbineAssertionError("Expected ${expected.qualifiedBy(name)} but found $eventAsString", cause) 254 | } 255 | 256 | internal fun String.qualifiedBy(name: String?) = 257 | if (name == null) { 258 | this 259 | } else { 260 | "$this for $name" 261 | } 262 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/app/cash/turbine/coroutines.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2022 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package app.cash.turbine 18 | 19 | import kotlin.coroutines.CoroutineContext 20 | import kotlin.time.Duration 21 | import kotlin.time.Duration.Companion.milliseconds 22 | import kotlinx.coroutines.CoroutineScope 23 | import kotlinx.coroutines.currentCoroutineContext 24 | import kotlinx.coroutines.withContext 25 | 26 | private val DEFAULT_TIMEOUT: Duration = 3000.milliseconds 27 | 28 | internal fun checkTimeout(timeout: Duration) { 29 | check(timeout.isPositive()) { "Turbine timeout must be greater than 0: $timeout" } 30 | } 31 | 32 | /** 33 | * Sets a timeout for all [Turbine] instances within this context. If this timeout is not set, 34 | * the default value is 3sec. 35 | */ 36 | public suspend fun withTurbineTimeout(timeout: Duration, block: suspend CoroutineScope.() -> T): T { 37 | checkTimeout(timeout) 38 | return withContext(TurbineTimeoutElement(timeout), block) 39 | } 40 | 41 | /** 42 | * Invoke this method to throw an error when your method is not being called by a suspend fun. 43 | * 44 | * This is usually used to prevent the usage of shared memory to communicate with code under 45 | * test in coroutines tests. [Communicating with shared memory is a bad idea](https://go.dev/blog/codelab-share). 46 | * 47 | * Concrete example: 48 | * 49 | * ``` 50 | * fun takeLastScreen(): Screen { 51 | * assertCallingContextIsNotSuspended() 52 | * 53 | * return screens.takeValue() 54 | * } 55 | * 56 | * @Test 57 | * fun myTest() = runBlocking { 58 | * assertCallingContextIsNotSuspended() // fine 59 | * takeLastScreen() // boom! 60 | * } 61 | * ``` 62 | */ 63 | internal fun assertCallingContextIsNotSuspended() { 64 | val stackTrace = Exception().stackTraceToString() 65 | // TODO: support non-JVM 66 | if ("invokeSuspend" in stackTrace) { 67 | error("Calling context is suspending; use a suspending method instead") 68 | } 69 | } 70 | 71 | internal class TurbineRegistryElement(val registry: MutableList>) : CoroutineContext.Element { 72 | companion object Key : CoroutineContext.Key 73 | 74 | override val key: CoroutineContext.Key<*> = Key 75 | } 76 | 77 | /** 78 | * Internal tool to report turbines that have been spun up within a given scope. 79 | * 80 | * If reportTurbines is nested within another reportTurbines, the outer scope wins: 81 | * no turbines will be registered from the inner scope. 82 | */ 83 | internal suspend fun reportTurbines(registry: MutableList>, block: suspend () -> T): T { 84 | val enclosingRegistryElement = currentCoroutineContext()[TurbineRegistryElement] 85 | return if (enclosingRegistryElement != null) { 86 | block() 87 | } else { 88 | withContext(TurbineRegistryElement(registry)) { 89 | block() 90 | } 91 | } 92 | } 93 | 94 | internal fun CoroutineScope.reportTurbine(turbine: ChannelTurbine<*>) = 95 | coroutineContext[TurbineRegistryElement]?.registry?.add(turbine) 96 | 97 | internal class TurbineTimeoutElement( 98 | val timeout: Duration, 99 | ) : CoroutineContext.Element { 100 | companion object Key : CoroutineContext.Key 101 | 102 | override val key: CoroutineContext.Key<*> = Key 103 | } 104 | 105 | internal suspend fun contextTimeout(): Duration { 106 | return currentCoroutineContext()[TurbineTimeoutElement.Key]?.timeout ?: DEFAULT_TIMEOUT 107 | } 108 | -------------------------------------------------------------------------------- /src/commonMain/kotlin/app/cash/turbine/flow.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2022 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.turbine 17 | 18 | import app.cash.turbine.testIn as testInExtension 19 | import kotlin.coroutines.CoroutineContext 20 | import kotlin.coroutines.EmptyCoroutineContext 21 | import kotlin.time.Duration 22 | import kotlinx.coroutines.CancellationException 23 | import kotlinx.coroutines.CoroutineScope 24 | import kotlinx.coroutines.CoroutineStart.UNDISPATCHED 25 | import kotlinx.coroutines.Dispatchers.Unconfined 26 | import kotlinx.coroutines.ExperimentalCoroutinesApi 27 | import kotlinx.coroutines.Job 28 | import kotlinx.coroutines.channels.Channel 29 | import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED 30 | import kotlinx.coroutines.coroutineScope 31 | import kotlinx.coroutines.currentCoroutineContext 32 | import kotlinx.coroutines.flow.Flow 33 | import kotlinx.coroutines.launch 34 | import kotlinx.coroutines.plus 35 | import kotlinx.coroutines.test.TestCoroutineScheduler 36 | import kotlinx.coroutines.test.UnconfinedTestDispatcher 37 | 38 | public interface TurbineContext : CoroutineScope { 39 | public fun Flow.testIn( 40 | scope: CoroutineScope, 41 | timeout: Duration? = null, 42 | name: String? = null, 43 | ): ReceiveTurbine 44 | } 45 | public interface TurbineTestContext : TurbineContext, ReceiveTurbine 46 | 47 | internal class TurbineTestContextImpl( 48 | turbine: ReceiveTurbine, 49 | turbineContext: CoroutineContext, 50 | ) : TurbineContext by TurbineContextImpl(turbineContext), ReceiveTurbine by turbine, TurbineTestContext 51 | 52 | internal class TurbineContextImpl( 53 | turbineContext: CoroutineContext, 54 | ) : TurbineContext, CoroutineScope { 55 | override val coroutineContext: CoroutineContext = turbineContext 56 | 57 | private val turbineElements = (turbineContext[TurbineRegistryElement] ?: EmptyCoroutineContext) + 58 | (turbineContext[TurbineTimeoutElement] ?: EmptyCoroutineContext) 59 | 60 | override fun Flow.testIn( 61 | scope: CoroutineScope, 62 | timeout: Duration?, 63 | name: String?, 64 | ): ReceiveTurbine = 65 | testInExtension( 66 | timeout = timeout, 67 | name = name, 68 | scope = scope + turbineElements, 69 | ) 70 | } 71 | 72 | /** 73 | * Run a validation block that catches and reports all unhandled exceptions in flows run by Turbine. 74 | */ 75 | public suspend fun turbineScope( 76 | timeout: Duration? = null, 77 | validate: suspend TurbineContext.() -> Unit, 78 | ) { 79 | val turbineRegistry = mutableListOf>() 80 | reportTurbines(turbineRegistry) { 81 | val scopeFn: suspend (suspend CoroutineScope.() -> Unit) -> Unit = { block -> 82 | if (timeout == null) { 83 | coroutineScope(block) 84 | } else { 85 | withTurbineTimeout(timeout, block) 86 | } 87 | } 88 | scopeFn { 89 | try { 90 | val testContext = TurbineContextImpl(currentCoroutineContext()) 91 | testContext.validate() 92 | } catch (e: Throwable) { 93 | // The exception needs to be reraised. However, if there are any unconsumed events 94 | // from other turbines (including this one), those may indicate an underlying problem. 95 | // So: create a report with all the registered turbines, and include exception as cause 96 | val reportsWithExceptions = turbineRegistry.map { 97 | it.reportUnconsumedEvents() 98 | // The exception will have cancelled its job hierarchy, producing cancellation exceptions 99 | // in its wake. These aren't meaningful test feedback 100 | .stripCancellations() 101 | } 102 | .filter { it.cause != null } 103 | if (reportsWithExceptions.isEmpty()) { 104 | throw e 105 | } else { 106 | throw TurbineAssertionError( 107 | buildString { 108 | reportsWithExceptions.forEach { 109 | it.describeException(this@buildString) 110 | } 111 | }, 112 | e, 113 | ) 114 | } 115 | } 116 | } 117 | } 118 | } 119 | 120 | /** 121 | * Terminal flow operator that collects events from given flow and allows the [validate] lambda to 122 | * consume and assert properties on them in order. If any exception occurs during validation the 123 | * exception is rethrown from this method. 124 | * 125 | * ```kotlin 126 | * flowOf("one", "two").test { 127 | * assertEquals("one", awaitItem()) 128 | * assertEquals("two", awaitItem()) 129 | * awaitComplete() 130 | * } 131 | * ``` 132 | * 133 | * @param timeout If non-null, overrides the current Turbine timeout inside [validate]. See also: 134 | * [withTurbineTimeout]. 135 | */ 136 | public suspend fun Flow.test( 137 | timeout: Duration? = null, 138 | name: String? = null, 139 | validate: suspend TurbineTestContext.() -> Unit, 140 | ) { 141 | turbineScope(timeout) { 142 | collectTurbineIn(this, null, name).apply { 143 | TurbineTestContextImpl(this, currentCoroutineContext()).validate() 144 | cancel() 145 | ensureAllEventsConsumed() 146 | } 147 | } 148 | } 149 | 150 | /** 151 | * Terminal flow operator that collects events from given flow and returns a [ReceiveTurbine] for 152 | * consuming and asserting properties on them in order. If any exception occurs during validation the 153 | * exception is rethrown from this method. 154 | * 155 | * ```kotlin 156 | * val turbine = flowOf("one", "two").testIn(this) 157 | * assertEquals("one", turbine.awaitItem()) 158 | * assertEquals("two", turbine.awaitItem()) 159 | * turbine.awaitComplete() 160 | * ``` 161 | * 162 | * Unlike [test] which automatically cancels the flow at the end of the lambda, the returned 163 | * [ReceiveTurbine] must either consume a terminal event (complete or error) or be explicitly canceled. 164 | * 165 | * @param timeout If non-null, overrides the current Turbine timeout for this [Turbine]. See also: 166 | * [withTurbineTimeout]. 167 | */ 168 | public fun Flow.testIn( 169 | scope: CoroutineScope, 170 | timeout: Duration? = null, 171 | name: String? = null, 172 | ): ReceiveTurbine { 173 | if (timeout != null) { 174 | // Eager check to throw early rather than in a subsequent 'await' call. 175 | checkTimeout(timeout) 176 | } 177 | if (scope.coroutineContext[TurbineRegistryElement] == null) { 178 | throw AssertionError("Turbine can only collect flows within a TurbineContext. Wrap with turbineScope { .. }") 179 | } 180 | 181 | val turbine = collectTurbineIn(scope, timeout, name) 182 | 183 | scope.coroutineContext[Job]?.invokeOnCompletion { exception -> 184 | if (debug) println("Scope ending ${exception ?: ""}") 185 | 186 | // Only validate events were consumed if the scope is exiting normally. 187 | // CancellationException also indicates _normal_ cancellation of a coroutine. 188 | if (exception == null || exception is CancellationException) { 189 | turbine.ensureAllEventsConsumed() 190 | } 191 | } 192 | 193 | return turbine 194 | } 195 | 196 | private fun Flow.collectTurbineIn(scope: CoroutineScope, timeout: Duration?, name: String?): ReceiveTurbine { 197 | // Use test-specific unconfined if test scheduler is in use to inherit its virtual time. 198 | @OptIn(ExperimentalCoroutinesApi::class) // UnconfinedTestDispatcher is still experimental. 199 | val unconfined = scope.coroutineContext[TestCoroutineScheduler] 200 | ?.let(::UnconfinedTestDispatcher) 201 | ?: Unconfined 202 | 203 | val output = Channel(UNLIMITED) 204 | val job = scope.launch(unconfined, start = UNDISPATCHED) { 205 | try { 206 | collect { output.trySend(it) } 207 | output.close() 208 | } catch (e: Throwable) { 209 | output.close(e) 210 | } 211 | } 212 | 213 | return ChannelTurbine(output, job, timeout, name).also { 214 | scope.reportTurbine(it) 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/app/cash/turbine/ChannelTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2022 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.turbine 17 | 18 | import kotlin.test.Test 19 | import kotlin.test.assertEquals 20 | import kotlin.test.assertFailsWith 21 | import kotlin.test.assertSame 22 | import kotlin.time.Duration.Companion.milliseconds 23 | import kotlinx.coroutines.Dispatchers 24 | import kotlinx.coroutines.Job 25 | import kotlinx.coroutines.channels.Channel 26 | import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED 27 | import kotlinx.coroutines.delay 28 | import kotlinx.coroutines.launch 29 | import kotlinx.coroutines.test.TestScope 30 | import kotlinx.coroutines.test.runTest 31 | import kotlinx.coroutines.withContext 32 | 33 | class ChannelTest { 34 | @Test 35 | fun exceptionsPropagateWhenExpectMostRecentItem() = runTest { 36 | val expected = CustomThrowable("hello") 37 | 38 | val actual = assertFailsWith { 39 | val channel = channelOf(1, 2, 3, closeCause = expected) 40 | channel.expectMostRecentItem() 41 | } 42 | assertSame(expected, actual) 43 | } 44 | 45 | @Test 46 | fun expectMostRecentItemButNoItemWasFoundThrows() = runTest { 47 | val actual = assertFailsWith { 48 | val channel = channelOf() 49 | channel.expectMostRecentItem() 50 | } 51 | assertEquals("No item was found", actual.message) 52 | } 53 | 54 | @Test 55 | fun expectMostRecentItem() = runTest { 56 | val channel = Channel(UNLIMITED) 57 | channel.trySend(1) 58 | channel.trySend(2) 59 | 60 | assertEquals(2, channel.expectMostRecentItem()) 61 | 62 | channel.trySend(3) 63 | channel.trySend(4) 64 | channel.trySend(5) 65 | assertEquals(5, channel.expectMostRecentItem()) 66 | } 67 | 68 | @Test 69 | fun assertNullValuesWithExpectMostRecentItem() = runTest { 70 | val channel = channelOf(1, 2, null) 71 | 72 | assertEquals(null, channel.expectMostRecentItem()) 73 | } 74 | 75 | @Test fun awaitItemsAreSkipped() = runTest { 76 | val channel = channelOf(1, 2, 3) 77 | channel.skipItems(2) 78 | assertEquals(3, channel.awaitItem()) 79 | } 80 | 81 | @Test fun skipItemsThrowsOnComplete() = runTest { 82 | val channel = channelOf(1, 2) 83 | val message = assertFailsWith { 84 | channel.skipItems(3) 85 | }.message 86 | assertEquals("Expected 3 items but got 2 items and Complete", message) 87 | } 88 | 89 | @Test fun expectErrorOnCompletionBeforeAllItemsWereSkipped() = runTest { 90 | val channel = channelOf(1) 91 | assertFailsWith { 92 | channel.skipItems(2) 93 | } 94 | } 95 | 96 | @Test fun expectErrorOnErrorReceivedBeforeAllItemsWereSkipped() = runTest { 97 | val error = CustomThrowable("hello") 98 | val channel = channelOf(1, closeCause = error) 99 | val actual = assertFailsWith { 100 | channel.skipItems(2) 101 | } 102 | assertSame(error, actual.cause) 103 | } 104 | 105 | @Test fun expectNoEvents() = runTest { 106 | val channel = neverChannel() 107 | channel.expectNoEvents() 108 | channel.cancel() 109 | } 110 | 111 | @Test fun awaitItemEvent() = runTest { 112 | val item = Any() 113 | val channel = channelOf(item) 114 | val event = channel.awaitEvent() 115 | assertEquals(Event.Item(item), event) 116 | } 117 | 118 | @Test fun expectCompleteEvent() = runTest { 119 | val channel = emptyChannel() 120 | val event = channel.awaitEvent() 121 | assertEquals(Event.Complete, event) 122 | } 123 | 124 | @Test fun expectErrorEvent() = runTest { 125 | val exception = CustomThrowable("hello") 126 | val channel = channelOf(closeCause = exception) 127 | val event = channel.awaitEvent() 128 | assertEquals(Event.Error(exception), event) 129 | } 130 | 131 | @Test fun awaitItem() = runTest { 132 | val item = Any() 133 | val channel = channelOf(item) 134 | assertSame(item, channel.awaitItem()) 135 | } 136 | 137 | @Test fun awaitItemButWasCloseThrows() = runTest { 138 | val actual = assertFailsWith { 139 | emptyChannel().awaitItem() 140 | } 141 | assertEquals("Expected item but found Complete", actual.message) 142 | } 143 | 144 | @Test fun awaitItemButWasErrorThrows() = runTest { 145 | val error = CustomThrowable("hello") 146 | val actual = assertFailsWith { 147 | channelOf(closeCause = error).awaitItem() 148 | } 149 | assertEquals("Expected item but found Error(CustomThrowable)", actual.message) 150 | assertSame(error, actual.cause) 151 | } 152 | 153 | @Test fun awaitComplete() = runTest { 154 | emptyChannel().awaitComplete() 155 | } 156 | 157 | @Test fun awaitCompleteButWasItemThrows() = runTest { 158 | val actual = assertFailsWith { 159 | channelOf("item!").awaitComplete() 160 | } 161 | assertEquals("Expected complete but found Item(item!)", actual.message) 162 | } 163 | 164 | @Test fun awaitCompleteButWasErrorThrows() = runTest { 165 | val error = CustomThrowable("hello") 166 | val actual = assertFailsWith { 167 | channelOf(closeCause = error).awaitComplete() 168 | } 169 | assertEquals("Expected complete but found Error(CustomThrowable)", actual.message) 170 | assertSame(error, actual.cause) 171 | } 172 | 173 | @Test fun awaitError() = runTest { 174 | val error = CustomThrowable("hello") 175 | val channel = channelOf(closeCause = error) 176 | assertSame(error, channel.awaitError()) 177 | } 178 | 179 | @Test fun awaitErrorButWasItemThrows() = runTest { 180 | val actual = assertFailsWith { 181 | channelOf("item!").awaitError() 182 | } 183 | assertEquals("Expected error but found Item(item!)", actual.message) 184 | } 185 | 186 | @Test fun awaitErrorButWasCompleteThrows() = runTest { 187 | val actual = assertFailsWith { 188 | emptyChannel().awaitError() 189 | } 190 | assertEquals("Expected error but found Complete", actual.message) 191 | } 192 | 193 | @Test fun failsOnDefaultTimeout() = runTest { 194 | val actual = assertFailsWith { 195 | neverChannel().awaitItem() 196 | } 197 | assertEquals("No value produced in 3s", actual.message) 198 | assertCallSitePresentInStackTraceOnJvm( 199 | throwable = actual, 200 | entryPoint = "ChannelKt.awaitItem", 201 | callSite = "ChannelTest\$failsOnDefaultTimeout", 202 | ) 203 | } 204 | 205 | @Test fun awaitHonorsCoroutineContextTimeoutNoTimeout() = runTest { 206 | withTurbineTimeout(1500.milliseconds) { 207 | val job = launch { 208 | neverChannel().awaitItem() 209 | } 210 | 211 | withContext(Dispatchers.Default) { 212 | delay(1100) 213 | } 214 | job.cancel() 215 | } 216 | } 217 | 218 | @Test fun awaitHonorsCoroutineContextTimeoutTimeout() = runTest { 219 | val actual = assertFailsWith { 220 | withTurbineTimeout(10.milliseconds) { 221 | neverChannel().awaitItem() 222 | } 223 | } 224 | assertEquals("No value produced in 10ms", actual.message) 225 | } 226 | 227 | @Test fun negativeTurbineTimeoutThrows() = runTest { 228 | val actual = assertFailsWith { 229 | withTurbineTimeout((-10).milliseconds) { 230 | } 231 | } 232 | assertEquals("Turbine timeout must be greater than 0: -10ms", actual.message) 233 | } 234 | 235 | @Test fun zeroTurbineTimeoutThrows() = runTest { 236 | val actual = assertFailsWith { 237 | withTurbineTimeout(0.milliseconds) { 238 | } 239 | } 240 | assertEquals("Turbine timeout must be greater than 0: 0s", actual.message) 241 | } 242 | 243 | @Test fun takeItem() = withTestScope { 244 | val item = Any() 245 | val channel = channelOf(item) 246 | assertSame(item, channel.takeItem()) 247 | } 248 | 249 | @Test fun takeItemButWasCloseThrows() = withTestScope { 250 | val actual = assertFailsWith { 251 | emptyChannel().takeItem() 252 | } 253 | assertEquals("Expected item but found Complete", actual.message) 254 | } 255 | 256 | @Test fun takeItemButWasErrorThrows() = withTestScope { 257 | val error = CustomThrowable("hello") 258 | val actual = assertFailsWith { 259 | channelOf(closeCause = error).takeItem() 260 | } 261 | assertEquals("Expected item but found Error(CustomThrowable)", actual.message) 262 | assertSame(error, actual.cause) 263 | } 264 | 265 | @Test 266 | fun expectMostRecentItemButNoItemWasFoundThrowsWithName() = runTest { 267 | val actual = assertFailsWith { 268 | emptyChannel().expectMostRecentItem(name = "empty flow") 269 | } 270 | assertEquals("No item was found for empty flow", actual.message) 271 | } 272 | 273 | @Test fun awaitItemButWasCloseThrowsWithName() = runTest { 274 | val actual = assertFailsWith { 275 | emptyChannel().awaitItem(name = "closed flow") 276 | } 277 | assertEquals("Expected item for closed flow but found Complete", actual.message) 278 | } 279 | 280 | @Test fun awaitCompleteButWasItemThrowsWithName() = runTest { 281 | val actual = assertFailsWith { 282 | channelOf("item!").awaitComplete(name = "item flow") 283 | } 284 | assertEquals("Expected complete for item flow but found Item(item!)", actual.message) 285 | } 286 | 287 | @Test fun awaitErrorButWasItemThrowsWithName() = runTest { 288 | val actual = assertFailsWith { 289 | channelOf("item!").awaitError(name = "item flow") 290 | } 291 | assertEquals("Expected error for item flow but found Item(item!)", actual.message) 292 | } 293 | 294 | @Test fun awaitHonorsCoroutineContextTimeoutTimeoutWithName() = runTest { 295 | val actual = assertFailsWith { 296 | withTurbineTimeout(10.milliseconds) { 297 | neverChannel().awaitItem(name = "never flow") 298 | } 299 | } 300 | assertEquals("No value produced for never flow in 10ms", actual.message) 301 | } 302 | 303 | @Test fun takeItemButWasCloseThrowsWithName() = withTestScope { 304 | val actual = assertFailsWith { 305 | emptyChannel().takeItem(name = "empty flow") 306 | } 307 | assertEquals("Expected item for empty flow but found Complete", actual.message) 308 | } 309 | 310 | @Test fun skipItemsThrowsOnCompleteWithName() = runTest { 311 | val channel = channelOf(1, 2) 312 | val message = assertFailsWith { 313 | channel.skipItems(3, name = "two item channel") 314 | }.message 315 | assertEquals("Expected 3 items for two item channel but got 2 items and Complete", message) 316 | } 317 | 318 | /** 319 | * Used to run test code with a [TestScope], but still outside a suspending context. 320 | */ 321 | private fun withTestScope(block: TestScope.() -> Unit) { 322 | val job = Job() 323 | 324 | TestScope(job).block() 325 | 326 | job.cancel() 327 | } 328 | } 329 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/app/cash/turbine/CustomThrowable.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2020 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.turbine 17 | 18 | /** 19 | * This type prevents coroutines from breaking referential equality by 20 | * reflectively creating new instances. 21 | */ 22 | internal class CustomThrowable( 23 | message: String?, 24 | override val cause: Throwable? = null, 25 | ) : Throwable(message) 26 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/app/cash/turbine/FlowInScopeTest.kt: -------------------------------------------------------------------------------- 1 | package app.cash.turbine 2 | 3 | import kotlin.test.Test 4 | import kotlin.test.assertContains 5 | import kotlin.test.assertEquals 6 | import kotlin.test.assertFailsWith 7 | import kotlin.test.assertFalse 8 | import kotlin.test.assertSame 9 | import kotlin.test.assertTrue 10 | import kotlin.time.Duration.Companion.milliseconds 11 | import kotlin.time.Duration.Companion.seconds 12 | import kotlinx.coroutines.CancellationException 13 | import kotlinx.coroutines.CompletionHandlerException 14 | import kotlinx.coroutines.CoroutineScope 15 | import kotlinx.coroutines.CoroutineStart 16 | import kotlinx.coroutines.Dispatchers.Default 17 | import kotlinx.coroutines.cancel 18 | import kotlinx.coroutines.channels.Channel 19 | import kotlinx.coroutines.coroutineScope 20 | import kotlinx.coroutines.delay 21 | import kotlinx.coroutines.flow.emitAll 22 | import kotlinx.coroutines.flow.emptyFlow 23 | import kotlinx.coroutines.flow.flow 24 | import kotlinx.coroutines.flow.flowOf 25 | import kotlinx.coroutines.flow.onCompletion 26 | import kotlinx.coroutines.flow.onStart 27 | import kotlinx.coroutines.launch 28 | import kotlinx.coroutines.test.runTest 29 | import kotlinx.coroutines.withContext 30 | 31 | class FlowInScopeTest { 32 | @Test fun multipleFlows() = runTestTurbine { 33 | val turbine1 = flowOf(1).testIn(this) 34 | val turbine2 = flowOf(2).testIn(this) 35 | assertEquals(1, turbine1.awaitItem()) 36 | assertEquals(2, turbine2.awaitItem()) 37 | turbine1.awaitComplete() 38 | turbine2.awaitComplete() 39 | } 40 | 41 | @Test 42 | fun channelCancellation() = runTestTurbine { 43 | kotlin.runCatching { 44 | coroutineScope { 45 | val channel = Channel() 46 | val job = launch { 47 | for (item in channel) { 48 | println("got something!") 49 | } 50 | } 51 | 52 | channel.cancel() 53 | 54 | println("job join result: ${runCatching { job.join() }}") 55 | println("job cancelled: ${job.isCancelled}") 56 | } 57 | }.let { println("result: $it") } 58 | kotlin.runCatching { 59 | coroutineScope { 60 | val channel = Channel() 61 | val job = launch { 62 | for (item in channel) { 63 | println("got something!") 64 | } 65 | } 66 | 67 | channel.close(CancellationException("it's me")) 68 | 69 | println("job join result: ${runCatching { job.join() }}") 70 | println("job cancelled: ${job.isCancelled}") 71 | } 72 | }.let { println("result: $it") } 73 | } 74 | 75 | @Test fun cancelMustBeCalled() = runTestTurbine { 76 | val job = launch { 77 | coroutineScope { 78 | neverFlow().testIn(this) 79 | } 80 | } 81 | // Wait on real dispatcher for wall clock time. This almost certainly means we'd wait forever. 82 | withContext(Default) { 83 | delay(1.seconds) 84 | } 85 | assertTrue(job.isActive) 86 | job.cancel() 87 | } 88 | 89 | @Test fun cancelStopsFlowCollection() = runTestTurbine { 90 | var collecting = false 91 | val turbine = neverFlow() 92 | .onStart { collecting = true } 93 | .onCompletion { collecting = false } 94 | .testIn(this) 95 | 96 | assertTrue(collecting) 97 | turbine.cancel() 98 | assertFalse(collecting) 99 | } 100 | 101 | @Test fun unconsumedItemThrowsWhenCancelledExternally() = runTestTurbine { 102 | // We have to use an exception handler rather than assertFailsWith because runTest also uses 103 | // one which defers throwing until its block completes. 104 | val exceptionHandler = RecordingExceptionHandler() 105 | launch(start = CoroutineStart.UNDISPATCHED) { 106 | withContext(exceptionHandler) { 107 | flow { 108 | emit("item!") 109 | emitAll(neverFlow()) // Avoid emitting complete 110 | }.testIn(this) 111 | } 112 | }.cancel() 113 | val exception = exceptionHandler.exceptions.removeFirst() 114 | assertTrue(exception is CompletionHandlerException) 115 | val cause = exception.cause 116 | assertTrue(cause is AssertionError) 117 | assertEquals( 118 | """ 119 | |Unconsumed events found: 120 | | - Item(item!) 121 | """.trimMargin(), 122 | cause.message, 123 | ) 124 | } 125 | 126 | @Test fun unconsumedItemThrows() = runTestTurbine { 127 | // We have to use an exception handler rather than assertFailsWith because runTest also uses 128 | // one which defers throwing until its block completes. 129 | val exceptionHandler = RecordingExceptionHandler() 130 | withContext(exceptionHandler) { 131 | flow { 132 | emit("item!") 133 | emitAll(neverFlow()) // Avoid emitting complete 134 | }.testIn(this).cancel() 135 | } 136 | val exception = exceptionHandler.exceptions.removeFirst() 137 | assertTrue(exception is CompletionHandlerException) 138 | val cause = exception.cause 139 | assertTrue(cause is AssertionError) 140 | assertEquals( 141 | """ 142 | |Unconsumed events found: 143 | | - Item(item!) 144 | """.trimMargin(), 145 | cause.message, 146 | ) 147 | } 148 | 149 | @Test fun unconsumedCompleteThrows() = runTestTurbine { 150 | // We have to use an exception handler rather than assertFailsWith because runTest also uses 151 | // one which defers throwing until its block completes. 152 | val exceptionHandler = RecordingExceptionHandler() 153 | withContext(exceptionHandler) { 154 | emptyFlow().testIn(this) 155 | } 156 | val exception = exceptionHandler.exceptions.removeFirst() 157 | assertTrue(exception is CompletionHandlerException) 158 | val cause = exception.cause 159 | assertTrue(cause is AssertionError) 160 | assertEquals( 161 | """ 162 | |Unconsumed events found: 163 | | - Complete 164 | """.trimMargin(), 165 | cause.message, 166 | ) 167 | } 168 | 169 | @Test fun unconsumedErrorThrows() = runTestTurbine { 170 | val expected = RuntimeException() 171 | // We have to use an exception handler rather than assertFailsWith because runTest also uses 172 | // one which defers throwing until its block completes. 173 | val exceptionHandler = RecordingExceptionHandler() 174 | withContext(exceptionHandler) { 175 | flow { throw expected }.testIn(this) 176 | } 177 | val exception = exceptionHandler.exceptions.removeFirst() 178 | assertTrue(exception is CompletionHandlerException) 179 | val cause = exception.cause 180 | assertTrue(cause is AssertionError) 181 | assertEquals( 182 | """ 183 | |Unconsumed events found: 184 | | - Error(RuntimeException) 185 | """.trimMargin(), 186 | cause.message, 187 | ) 188 | assertSame(expected, cause.cause) 189 | } 190 | 191 | @Test fun failsOnDefaultTimeout() = runTestTurbine { 192 | val turbine = neverFlow().testIn(this) 193 | val actual = assertFailsWith { 194 | turbine.awaitItem() 195 | } 196 | assertEquals("No value produced in 3s", actual.message) 197 | assertCallSitePresentInStackTraceOnJvm( 198 | throwable = actual, 199 | entryPoint = "ChannelTurbine\$awaitItem", 200 | callSite = "FlowInScopeTest\$failsOnDefaultTimeout", 201 | ) 202 | turbine.cancel() 203 | } 204 | 205 | @Test fun awaitHonorsTestTimeoutNoTimeout() = runTestTurbine { 206 | val turbine = flow { 207 | withContext(Default) { 208 | delay(1100.milliseconds) 209 | } 210 | }.testIn(this, timeout = 1500.milliseconds) 211 | turbine.awaitComplete() 212 | } 213 | 214 | @Test fun awaitHonorsCoroutineContextTimeoutTimeout() = runTestTurbine { 215 | val turbine = neverFlow().testIn(this, timeout = 10.milliseconds) 216 | val actual = assertFailsWith { 217 | turbine.awaitItem() 218 | } 219 | assertEquals("No value produced in 10ms", actual.message) 220 | turbine.cancel() 221 | } 222 | 223 | @Test fun negativeTurbineTimeoutThrows() = runTestTurbine { 224 | val actual = assertFailsWith { 225 | neverFlow().testIn(this, timeout = (-10).milliseconds) 226 | } 227 | assertEquals("Turbine timeout must be greater than 0: -10ms", actual.message) 228 | } 229 | 230 | @Test fun zeroTurbineTimeoutThrows() = runTestTurbine { 231 | val actual = assertFailsWith { 232 | neverFlow().testIn(this, timeout = 0.milliseconds) 233 | } 234 | assertEquals("Turbine timeout must be greater than 0: 0s", actual.message) 235 | } 236 | 237 | @Test fun expectItemButWasErrorThrowsWithName() = runTestTurbine { 238 | val error = CustomThrowable("hi") 239 | val actual = assertFailsWith { 240 | flow { throw error }.testIn(this, name = "unit flow") 241 | .awaitItem() 242 | } 243 | assertEquals("Expected item for unit flow but found Error(CustomThrowable)", actual.message) 244 | assertSame(error, actual.cause) 245 | } 246 | 247 | @Test fun timeoutThrowsWithName() = runTestTurbine { 248 | val turbine = neverFlow().testIn(this, timeout = 10.milliseconds, name = "never flow") 249 | val actual = assertFailsWith { 250 | turbine.awaitItem() 251 | } 252 | assertEquals("No value produced for never flow in 10ms", actual.message) 253 | turbine.cancel() 254 | } 255 | 256 | @Test fun unconsumedItemThrowsWithName() = runTestTurbine { 257 | // We have to use an exception handler rather than assertFailsWith because runTest also uses 258 | // one which defers throwing until its block completes. 259 | val exceptionHandler = RecordingExceptionHandler() 260 | withContext(exceptionHandler) { 261 | flow { 262 | emit("item!") 263 | emitAll(neverFlow()) // Avoid emitting complete 264 | }.testIn(this, name = "item flow").cancel() 265 | } 266 | val exception = exceptionHandler.exceptions.removeFirst() 267 | assertTrue(exception is CompletionHandlerException) 268 | val cause = exception.cause 269 | assertTrue(cause is AssertionError) 270 | assertEquals( 271 | """ 272 | |Unconsumed events found for item flow: 273 | | - Item(item!) 274 | """.trimMargin(), 275 | cause.message, 276 | ) 277 | } 278 | 279 | @Test 280 | fun innerFailingFlowIsReported() = runTest { 281 | val expected = CustomThrowable("hi") 282 | 283 | val actual = assertFailsWith { 284 | turbineScope { 285 | flow { 286 | throw expected 287 | }.testIn(backgroundScope, name = "inner failing") 288 | 289 | Turbine(name = "inner").awaitItem() 290 | } 291 | } 292 | 293 | val expectedPrefix = """ 294 | |Unconsumed exception found for inner failing: 295 | | 296 | |Stack trace: 297 | """.trimMargin() 298 | assertEquals( 299 | actual.message?.startsWith( 300 | expectedPrefix, 301 | ), 302 | true, 303 | "Expected to start with:\n\n$expectedPrefix\n\nBut was:\n\n${actual.message}", 304 | ) 305 | assertContains( 306 | actual.message!!, 307 | "CustomThrowable: hi", 308 | ) 309 | assertEquals( 310 | actual.cause?.message, 311 | "No value produced for inner in 3s", 312 | ) 313 | } 314 | 315 | @Test 316 | fun failWithoutTurbineScope() = runTest { 317 | val actual = assertFailsWith { 318 | emptyFlow().testIn(backgroundScope, name = "inner failing") 319 | } 320 | assertEquals( 321 | "Turbine can only collect flows within a TurbineContext. Wrap with turbineScope { .. }", 322 | actual.message, 323 | ) 324 | } 325 | } 326 | 327 | private interface TurbineTestScope : TurbineContext { 328 | val backgroundScope: CoroutineScope 329 | } 330 | 331 | private fun runTestTurbine(validate: suspend TurbineTestScope.() -> Unit) = runTest { 332 | turbineScope { 333 | val turbineTestScope = object : TurbineTestScope, TurbineContext by this { 334 | override val backgroundScope: CoroutineScope = this@runTest.backgroundScope 335 | } 336 | 337 | turbineTestScope.validate() 338 | } 339 | } 340 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/app/cash/turbine/FlowTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2020 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.turbine 17 | 18 | import kotlin.coroutines.cancellation.CancellationException 19 | import kotlin.test.Test 20 | import kotlin.test.assertContains 21 | import kotlin.test.assertEquals 22 | import kotlin.test.assertFailsWith 23 | import kotlin.test.assertFalse 24 | import kotlin.test.assertSame 25 | import kotlin.test.assertTrue 26 | import kotlin.time.Duration.Companion.milliseconds 27 | import kotlin.time.Duration.Companion.seconds 28 | import kotlin.time.ExperimentalTime 29 | import kotlin.time.measureTime 30 | import kotlinx.coroutines.CompletableDeferred 31 | import kotlinx.coroutines.CoroutineStart.UNDISPATCHED 32 | import kotlinx.coroutines.Dispatchers.Default 33 | import kotlinx.coroutines.Dispatchers.Unconfined 34 | import kotlinx.coroutines.Job 35 | import kotlinx.coroutines.NonCancellable 36 | import kotlinx.coroutines.TimeoutCancellationException 37 | import kotlinx.coroutines.channels.Channel 38 | import kotlinx.coroutines.channels.Channel.Factory.RENDEZVOUS 39 | import kotlinx.coroutines.currentCoroutineContext 40 | import kotlinx.coroutines.delay 41 | import kotlinx.coroutines.flow.MutableSharedFlow 42 | import kotlinx.coroutines.flow.MutableStateFlow 43 | import kotlinx.coroutines.flow.emitAll 44 | import kotlinx.coroutines.flow.emptyFlow 45 | import kotlinx.coroutines.flow.flow 46 | import kotlinx.coroutines.flow.flowOf 47 | import kotlinx.coroutines.flow.map 48 | import kotlinx.coroutines.flow.onCompletion 49 | import kotlinx.coroutines.flow.onStart 50 | import kotlinx.coroutines.launch 51 | import kotlinx.coroutines.suspendCancellableCoroutine 52 | import kotlinx.coroutines.test.advanceTimeBy 53 | import kotlinx.coroutines.test.runCurrent 54 | import kotlinx.coroutines.test.runTest 55 | import kotlinx.coroutines.withContext 56 | import kotlinx.coroutines.withTimeout 57 | import kotlinx.coroutines.yield 58 | 59 | class FlowTest { 60 | @Test fun exceptionsPropagate() = runTest { 61 | val expected = CustomThrowable("hello") 62 | 63 | val actual = assertFailsWith { 64 | neverFlow().test { 65 | throw expected 66 | } 67 | } 68 | assertSame(expected, actual) 69 | } 70 | 71 | @Test fun cancelStopsFlowCollection() = runTest { 72 | var collecting = false 73 | neverFlow() 74 | .onStart { collecting = true } 75 | .onCompletion { collecting = false } 76 | .test { 77 | assertTrue(collecting) 78 | cancel() 79 | assertFalse(collecting) 80 | } 81 | } 82 | 83 | @Test fun cancelAwaitsFlowCompletion() = runTest { 84 | var collecting = false 85 | neverFlow() 86 | .onStart { collecting = true } 87 | .onCompletion { 88 | withContext(NonCancellable) { 89 | yield() 90 | collecting = false 91 | } 92 | } 93 | .test { 94 | assertTrue(collecting) 95 | cancel() 96 | assertFalse(collecting) 97 | } 98 | } 99 | 100 | @Test fun endOfBlockStopsFlowCollection() = runTest { 101 | var collecting = false 102 | neverFlow() 103 | .onStart { collecting = true } 104 | .onCompletion { collecting = false } 105 | .test { 106 | assertTrue(collecting) 107 | } 108 | assertFalse(collecting) 109 | } 110 | 111 | @Test fun exceptionStopsFlowCollection() = runTest { 112 | var collecting = false 113 | assertFailsWith { 114 | neverFlow() 115 | .onStart { collecting = true } 116 | .onCompletion { collecting = false } 117 | .test { 118 | assertTrue(collecting) 119 | throw RuntimeException() 120 | } 121 | } 122 | assertFalse(collecting) 123 | } 124 | 125 | @Test fun ignoreRemainingEventsStopsFlowCollection() = runTest { 126 | var collecting = false 127 | neverFlow() 128 | .onStart { collecting = true } 129 | .onCompletion { collecting = false } 130 | .test { 131 | assertTrue(collecting) 132 | cancelAndIgnoreRemainingEvents() 133 | } 134 | assertFalse(collecting) 135 | } 136 | 137 | @Test fun expectNoEvents() = runTest { 138 | neverFlow().test { 139 | expectNoEvents() 140 | cancel() 141 | } 142 | } 143 | 144 | @Test fun unconsumedItemThrows() = runTest { 145 | val actual = assertFailsWith { 146 | flow { 147 | emit("item!") 148 | emitAll(neverFlow()) // Avoid emitting complete 149 | }.test { } 150 | } 151 | assertEquals( 152 | """ 153 | |Unconsumed events found: 154 | | - Item(item!) 155 | """.trimMargin(), 156 | actual.message, 157 | ) 158 | } 159 | 160 | @Test 161 | fun expectNoEventsFailsOnException() = runTest { 162 | val expected = RuntimeException() 163 | val actual = assertFailsWith { 164 | flow { 165 | throw expected 166 | }.test { 167 | expectNoEvents() 168 | } 169 | } 170 | assertEquals( 171 | """ 172 | |Expected no events but found Error(RuntimeException) 173 | """.trimMargin(), 174 | actual.message, 175 | ) 176 | assertSame(expected, actual.cause) 177 | } 178 | 179 | @Test 180 | fun expectNoEventsFailsOnCompletion() = runTest { 181 | val actual = assertFailsWith { 182 | emptyFlow().test { 183 | expectNoEvents() 184 | } 185 | } 186 | assertEquals( 187 | """ 188 | |Expected no events but found Complete 189 | """.trimMargin(), 190 | actual.message, 191 | ) 192 | } 193 | 194 | @Test fun unconsumedCompleteThrows() = runTest { 195 | val actual = assertFailsWith { 196 | emptyFlow().test { } 197 | } 198 | assertEquals( 199 | """ 200 | |Unconsumed events found: 201 | | - Complete 202 | """.trimMargin(), 203 | actual.message, 204 | ) 205 | } 206 | 207 | @Test fun unconsumedErrorThrows() = runTest { 208 | val expected = RuntimeException() 209 | val actual = assertFailsWith { 210 | flow { throw expected }.test { } 211 | } 212 | assertEquals( 213 | """ 214 | |Unconsumed events found: 215 | | - Error(RuntimeException) 216 | """.trimMargin(), 217 | actual.message, 218 | ) 219 | assertSame(expected, actual.cause) 220 | } 221 | 222 | @Test fun unconsumedItemThrowsWithCancel() = runTest { 223 | val actual = assertFailsWith { 224 | flow { 225 | emit("one") 226 | emit("two") 227 | emitAll(neverFlow()) // Avoid emitting complete 228 | }.test { 229 | // Expect one item to ensure we start collecting and receive both items. 230 | assertEquals("one", awaitItem()) 231 | cancel() 232 | } 233 | } 234 | assertEquals( 235 | """ 236 | |Unconsumed events found: 237 | | - Item(two) 238 | """.trimMargin(), 239 | actual.message, 240 | ) 241 | } 242 | 243 | @Test fun unconsumedCompleteThrowsWithCancel() = runTest { 244 | val actual = assertFailsWith { 245 | flowOf("one").test { 246 | // Expect one item to ensure we start collecting and receive complete. 247 | assertEquals("one", awaitItem()) 248 | cancel() 249 | } 250 | } 251 | assertEquals( 252 | """ 253 | |Unconsumed events found: 254 | | - Complete 255 | """.trimMargin(), 256 | actual.message, 257 | ) 258 | } 259 | 260 | @Test fun unconsumedErrorThrowsWithCancel() = runTest { 261 | val expected = RuntimeException() 262 | val actual = assertFailsWith { 263 | flow { 264 | emit("one") 265 | throw expected 266 | }.test { 267 | // Expect one item to ensure we start collecting and receive the exception. 268 | assertEquals("one", awaitItem()) 269 | cancel() 270 | } 271 | } 272 | assertEquals( 273 | """ 274 | |Unconsumed events found: 275 | | - Error(RuntimeException) 276 | """.trimMargin(), 277 | actual.message, 278 | ) 279 | assertSame(expected, actual.cause) 280 | } 281 | 282 | @Test fun unconsumedItemReturnedWithConsumingCancel() = runTest { 283 | flow { 284 | emit("one") 285 | emit("two") 286 | emitAll(neverFlow()) // Avoid emitting complete 287 | }.test { 288 | // Expect one item to ensure we start collecting and receive both items. 289 | assertEquals("one", awaitItem()) 290 | 291 | val remaining = cancelAndConsumeRemainingEvents() 292 | assertEquals(listOf(Event.Item("two")), remaining) 293 | } 294 | } 295 | 296 | @Test fun unconsumedCompleteReturnedWithConsumingCancel() = runTest { 297 | flowOf("one").test { 298 | // Expect one item to ensure we start collecting and receive complete. 299 | assertEquals("one", awaitItem()) 300 | 301 | val remaining = cancelAndConsumeRemainingEvents() 302 | assertEquals(listOf(Event.Complete), remaining) 303 | } 304 | } 305 | 306 | @Test fun unconsumedErrorReturnedWithConsumingCancel() = runTest { 307 | val expected = RuntimeException() 308 | flow { 309 | emit("one") 310 | throw expected 311 | }.test { 312 | // Expect one item to ensure we start collecting and receive the exception. 313 | assertEquals("one", awaitItem()) 314 | 315 | val remaining = cancelAndConsumeRemainingEvents() 316 | assertEquals(listOf(Event.Error(expected)), remaining) 317 | } 318 | } 319 | 320 | @Test fun unconsumedItemCanBeIgnored() = runTest { 321 | flowOf("item!").test { 322 | cancelAndIgnoreRemainingEvents() 323 | } 324 | } 325 | 326 | @Test fun unconsumedCompleteCanBeIgnored() = runTest { 327 | emptyFlow().test { 328 | cancelAndIgnoreRemainingEvents() 329 | } 330 | } 331 | 332 | @Test fun unconsumedErrorCanBeIgnored() = runTest { 333 | flow { throw RuntimeException() }.test { 334 | cancelAndIgnoreRemainingEvents() 335 | } 336 | } 337 | 338 | @Test fun awaitItem() = runTest { 339 | val item = Any() 340 | flowOf(item).test { 341 | assertSame(item, awaitItem()) 342 | cancelAndIgnoreRemainingEvents() 343 | } 344 | } 345 | 346 | @Test fun awaitItemButWasCloseThrows() = runTest { 347 | val actual = assertFailsWith { 348 | emptyFlow().test { 349 | awaitItem() 350 | } 351 | } 352 | assertEquals("Expected item but found Complete", actual.message) 353 | } 354 | 355 | @Test fun awaitItemButWasErrorThrows() = runTest { 356 | val error = CustomThrowable("hi") 357 | val actual = assertFailsWith { 358 | flow { throw error }.test { 359 | awaitItem() 360 | } 361 | } 362 | assertEquals("Expected item but found Error(CustomThrowable)", actual.message) 363 | assertSame(error, actual.cause) 364 | } 365 | 366 | @Test fun awaitComplete() = runTest { 367 | emptyFlow().test { 368 | awaitComplete() 369 | } 370 | } 371 | 372 | @Test fun awaitCompleteButWasItemThrows() = runTest { 373 | val actual = assertFailsWith { 374 | flowOf("item!").test { 375 | awaitComplete() 376 | } 377 | } 378 | assertEquals("Expected complete but found Item(item!)", actual.message) 379 | } 380 | 381 | @Test fun awaitCompleteButWasErrorThrows() = runTest { 382 | val error = CustomThrowable("hi") 383 | val actual = assertFailsWith { 384 | flow { throw error }.test { 385 | awaitComplete() 386 | } 387 | } 388 | assertEquals("Expected complete but found Error(CustomThrowable)", actual.message) 389 | assertSame(error, actual.cause) 390 | } 391 | 392 | @Test fun awaitError() = runTest { 393 | val error = CustomThrowable("hi") 394 | flow { throw error }.test { 395 | assertSame(error, awaitError()) 396 | } 397 | } 398 | 399 | @Test fun terminalErrorAfterExpectMostRecentItemThrows() = runTest { 400 | val error = RuntimeException("hi") 401 | val throwBarrier = Job() 402 | val message = assertFailsWith { 403 | flow { 404 | emit("item!") 405 | throwBarrier.join() 406 | throw error 407 | }.test { 408 | expectMostRecentItem() 409 | throwBarrier.complete() 410 | } 411 | }.message 412 | 413 | assertEquals( 414 | """ 415 | |Unconsumed events found: 416 | | - Error(RuntimeException) 417 | """.trimMargin(), 418 | message, 419 | ) 420 | } 421 | 422 | @Test fun awaitErrorButWasItemThrows() = runTest { 423 | val actual = assertFailsWith { 424 | flowOf("item!").test { 425 | awaitError() 426 | } 427 | } 428 | assertEquals("Expected error but found Item(item!)", actual.message) 429 | } 430 | 431 | @Test fun awaitErrorButWasCompleteThrows() = runTest { 432 | val actual = assertFailsWith { 433 | emptyFlow().test { 434 | awaitError() 435 | } 436 | } 437 | assertEquals("Expected error but found Complete", actual.message) 438 | } 439 | 440 | @Test fun awaitItemEvent() = runTest { 441 | val item = Any() 442 | flowOf(item).test { 443 | val event = awaitEvent() 444 | assertEquals(Event.Item(item), event) 445 | cancelAndIgnoreRemainingEvents() 446 | } 447 | } 448 | 449 | @Test fun awaitCompleteEvent() = runTest { 450 | emptyFlow().test { 451 | val event = awaitEvent() 452 | assertEquals(Event.Complete, event) 453 | cancelAndIgnoreRemainingEvents() 454 | } 455 | } 456 | 457 | @Test fun awaitErrorEvent() = runTest { 458 | val exception = CustomThrowable("hi") 459 | flow { throw exception }.test { 460 | val event = awaitEvent() 461 | assertEquals(Event.Error(exception), event) 462 | cancelAndIgnoreRemainingEvents() 463 | } 464 | } 465 | 466 | @Test fun awaitWaitsForEvents() = runTest { 467 | val flow = MutableSharedFlow() 468 | val position = Channel(RENDEZVOUS) 469 | 470 | // Start undispatched so we suspend inside the test{} block. 471 | launch(start = UNDISPATCHED, context = Unconfined) { 472 | flow.test { 473 | position.send(1) 474 | assertEquals("one", awaitItem()) 475 | position.send(2) 476 | assertEquals("two", awaitItem()) 477 | position.send(3) 478 | cancel() 479 | } 480 | } 481 | 482 | assertEquals(1, position.receive()) 483 | 484 | flow.emit("one") 485 | assertEquals(2, position.receive()) 486 | 487 | flow.emit("two") 488 | assertEquals(3, position.receive()) 489 | } 490 | 491 | @Test fun exceptionsPropagateWhenExpectMostRecentItem() = runTest { 492 | val expected = CustomThrowable("hello") 493 | 494 | val actual = assertFailsWith { 495 | flow { 496 | emit(1) 497 | emit(2) 498 | emit(3) 499 | throw expected 500 | }.test { 501 | expectMostRecentItem() 502 | } 503 | } 504 | assertSame(expected, actual) 505 | } 506 | 507 | @Test fun expectMostRecentItemButNoItemWasFoundThrows() = runTest { 508 | val actual = assertFailsWith { 509 | emptyFlow().test { 510 | expectMostRecentItem() 511 | } 512 | } 513 | assertEquals("No item was found", actual.message) 514 | } 515 | 516 | @Test fun expectMostRecentItem() = runTest { 517 | val onTwoSent = CompletableDeferred() 518 | val onTwoContinue = CompletableDeferred() 519 | val onCompleteSent = CompletableDeferred() 520 | val onCompleteContinue = CompletableDeferred() 521 | 522 | flowOf(1, 2, 3, 4, 5) 523 | .map { 524 | if (it == 3) { 525 | onTwoSent.complete(Unit) 526 | onTwoContinue.await() 527 | } 528 | it 529 | } 530 | .onCompletion { 531 | onCompleteSent.complete(Unit) 532 | onCompleteContinue.await() 533 | } 534 | .test { 535 | onTwoSent.await() 536 | assertEquals(2, expectMostRecentItem()) 537 | onTwoContinue.complete(Unit) 538 | 539 | onCompleteSent.await() 540 | assertEquals(5, expectMostRecentItem()) 541 | onCompleteContinue.complete(Unit) 542 | awaitComplete() 543 | } 544 | } 545 | 546 | @Test fun valuesDoNotConflate() = runTest { 547 | val flow = MutableStateFlow(0) 548 | flow.test { 549 | flow.value = 1 550 | flow.value = 2 551 | flow.value = 3 552 | assertEquals(0, awaitItem()) 553 | assertEquals(1, awaitItem()) 554 | assertEquals(2, awaitItem()) 555 | assertEquals(3, awaitItem()) 556 | } 557 | } 558 | 559 | @Test fun assertNullValuesWithExpectMostRecentItem() = runTest { 560 | flowOf(1, 2, null).test { 561 | assertEquals(null, expectMostRecentItem()) 562 | cancelAndIgnoreRemainingEvents() 563 | } 564 | } 565 | 566 | @Test fun expectItemsAreSkipped() = runTest { 567 | flowOf(1, 2, 3).test { 568 | skipItems(2) 569 | assertEquals(3, awaitItem()) 570 | awaitComplete() 571 | } 572 | } 573 | 574 | @Test fun skipItemsThrowsOnComplete() = runTest { 575 | flowOf(1, 2).test { 576 | val message = assertFailsWith { 577 | skipItems(3) 578 | }.message 579 | assertEquals("Expected 3 items but got 2 items and Complete", message) 580 | } 581 | } 582 | 583 | @Test fun expectErrorOnCompletionBeforeAllItemsWereSkipped() = runTest { 584 | flowOf(1).test { 585 | assertFailsWith { 586 | skipItems(2) 587 | } 588 | } 589 | } 590 | 591 | @Test fun expectErrorOnErrorReceivedBeforeAllItemsWereSkipped() = runTest { 592 | val error = CustomThrowable("hi") 593 | flow { 594 | emit(1) 595 | throw error 596 | }.test { 597 | val actual = assertFailsWith { 598 | skipItems(2) 599 | } 600 | assertSame(error, actual.cause) 601 | } 602 | } 603 | 604 | @OptIn(ExperimentalTime::class) 605 | @Test 606 | fun turbineSkipsDelaysInRunTest() = runTest { 607 | val took = measureTime { 608 | flow { 609 | delay(5.seconds) 610 | }.test { 611 | awaitComplete() 612 | } 613 | } 614 | assertTrue(took < 5.seconds, "$took > 5s") 615 | } 616 | 617 | @Test fun failsOnDefaultTimeout() = runTest { 618 | neverFlow().test { 619 | val actual = assertFailsWith { 620 | awaitItem() 621 | } 622 | assertEquals("No value produced in 3s", actual.message) 623 | assertCallSitePresentInStackTraceOnJvm( 624 | throwable = actual, 625 | entryPoint = "ChannelTurbine\$awaitItem", 626 | callSite = "FlowTest\$failsOnDefaultTimeout", 627 | ) 628 | } 629 | } 630 | 631 | @Test fun awaitHonorsTestTimeoutNoTimeout() = runTest { 632 | flow { 633 | withContext(Default) { 634 | delay(1100.milliseconds) 635 | } 636 | }.test(timeout = 1500.milliseconds) { 637 | awaitComplete() 638 | } 639 | } 640 | 641 | @Test fun awaitHonorsCoroutineContextTimeoutTimeout() = runTest { 642 | neverFlow().test(timeout = 10.milliseconds) { 643 | val actual = assertFailsWith { 644 | awaitItem() 645 | } 646 | assertEquals("No value produced in 10ms", actual.message) 647 | } 648 | } 649 | 650 | @Test fun negativeTurbineTimeoutThrows() = runTest { 651 | val actual = assertFailsWith { 652 | neverFlow().test(timeout = (-10).milliseconds) { 653 | } 654 | } 655 | assertEquals("Turbine timeout must be greater than 0: -10ms", actual.message) 656 | } 657 | 658 | @Test fun zeroTurbineTimeoutThrows() = runTest { 659 | val actual = assertFailsWith { 660 | neverFlow().test(timeout = 0.milliseconds) { 661 | } 662 | } 663 | assertEquals("Turbine timeout must be greater than 0: 0s", actual.message) 664 | } 665 | 666 | @Test fun awaitItemButWasErrorThrowsWithName() = runTest { 667 | val error = CustomThrowable("hi") 668 | val actual = assertFailsWith { 669 | flow { throw error }.test(name = "unit flow") { 670 | awaitItem() 671 | } 672 | } 673 | assertEquals("Expected item for unit flow but found Error(CustomThrowable)", actual.message) 674 | assertSame(error, actual.cause) 675 | } 676 | 677 | @Test fun timeoutThrowsWithName() = runTest { 678 | neverFlow().test(timeout = 10.milliseconds, name = "never flow") { 679 | val actual = assertFailsWith { 680 | awaitItem() 681 | } 682 | assertEquals("No value produced for never flow in 10ms", actual.message) 683 | } 684 | } 685 | 686 | @Test fun unconsumedItemThrowsWithName() = runTest { 687 | val actual = assertFailsWith { 688 | flow { 689 | emit("item!") 690 | emitAll(neverFlow()) // Avoid emitting complete 691 | }.test(name = "item flow") { } 692 | } 693 | assertEquals( 694 | """ 695 | |Unconsumed events found for item flow: 696 | | - Item(item!) 697 | """.trimMargin(), 698 | actual.message, 699 | ) 700 | } 701 | 702 | @Test fun skipItemsThrowsOnCompleteWithName() = runTest { 703 | flowOf(1, 2).test(name = "two item channel") { 704 | val message = assertFailsWith { 705 | skipItems(3) 706 | }.message 707 | assertEquals("Expected 3 items for two item channel but got 2 items and Complete", message) 708 | } 709 | } 710 | 711 | @Test 712 | fun virtualTimeCanBeControlled() = runTest { 713 | flow { 714 | delay(5000) 715 | emit("1") 716 | delay(5000) 717 | emit("2") 718 | }.test { 719 | expectNoEvents() 720 | 721 | advanceTimeBy(5000) 722 | expectNoEvents() 723 | 724 | runCurrent() 725 | assertEquals("1", awaitItem()) 726 | 727 | advanceTimeBy(5000) 728 | expectNoEvents() 729 | 730 | runCurrent() 731 | assertEquals("2", awaitItem()) 732 | 733 | awaitComplete() 734 | } 735 | } 736 | 737 | @Test 738 | fun timeoutsAreCaptured() = runTest { 739 | flow { 740 | withTimeout(500) { 741 | delay(2000) 742 | } 743 | }.test { 744 | assertTrue(awaitError() is TimeoutCancellationException) 745 | } 746 | } 747 | 748 | @Test 749 | fun cancellationsAreCaptured() = runTest { 750 | flow { 751 | currentCoroutineContext()[Job]!!.cancel() 752 | suspendCancellableCoroutine { } 753 | }.test { 754 | assertTrue(awaitError() is CancellationException) 755 | } 756 | } 757 | 758 | @Test 759 | fun outerFailingFlowIsReported() = runTest { 760 | val expected = CustomThrowable("hi") 761 | 762 | val actual = assertFailsWith { 763 | flow { 764 | throw expected 765 | }.test(name = "outer") { 766 | Turbine(name = "inner").awaitItem() 767 | } 768 | } 769 | 770 | val expectedPrefix = """ 771 | |Unconsumed exception found for outer: 772 | | 773 | |Stack trace: 774 | """.trimMargin() 775 | assertEquals( 776 | actual.message?.startsWith( 777 | expectedPrefix, 778 | ), 779 | true, 780 | "Expected to start with:\n\n$expectedPrefix\n\nBut was:\n\n${actual.message}", 781 | ) 782 | assertContains( 783 | actual.message!!, 784 | "CustomThrowable: hi", 785 | ) 786 | assertEquals(actual.cause?.message, "No value produced for inner in 3s") 787 | } 788 | 789 | @Test 790 | fun innerFailingFlowIsReported() = runTest { 791 | val expected = CustomThrowable("hi") 792 | 793 | val actual = assertFailsWith { 794 | neverFlow().test(name = "outer") { 795 | flow { 796 | throw expected 797 | }.testIn(backgroundScope, name = "inner failing") 798 | 799 | Turbine(name = "inner").awaitItem() 800 | } 801 | } 802 | 803 | val expectedPrefix = """ 804 | |Unconsumed exception found for inner failing: 805 | | 806 | |Stack trace: 807 | """.trimMargin() 808 | assertEquals( 809 | actual.message?.startsWith( 810 | expectedPrefix, 811 | ), 812 | true, 813 | "Expected to start with:\n\n$expectedPrefix\n\nBut was:\n\n${actual.message}", 814 | ) 815 | assertContains( 816 | actual.message!!, 817 | "CustomThrowable: hi", 818 | ) 819 | assertEquals( 820 | actual.cause?.message, 821 | "No value produced for inner in 3s", 822 | ) 823 | } 824 | } 825 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/app/cash/turbine/RecordingExceptionHandler.kt: -------------------------------------------------------------------------------- 1 | package app.cash.turbine 2 | 3 | import kotlin.coroutines.CoroutineContext 4 | import kotlinx.coroutines.CoroutineExceptionHandler 5 | 6 | class RecordingExceptionHandler : CoroutineExceptionHandler { 7 | val exceptions = ArrayDeque() 8 | 9 | override val key get() = CoroutineExceptionHandler.Key 10 | 11 | override fun handleException(context: CoroutineContext, exception: Throwable) { 12 | exceptions += exception 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/app/cash/turbine/TurbineTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2022 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.turbine 17 | 18 | import kotlin.test.Test 19 | import kotlin.test.assertEquals 20 | import kotlin.test.assertFailsWith 21 | import kotlin.test.assertNull 22 | import kotlin.test.assertSame 23 | import kotlinx.coroutines.CompletableDeferred 24 | import kotlinx.coroutines.CoroutineScope 25 | import kotlinx.coroutines.CoroutineStart 26 | import kotlinx.coroutines.Dispatchers 27 | import kotlinx.coroutines.Job 28 | import kotlinx.coroutines.launch 29 | import kotlinx.coroutines.test.TestScope 30 | import kotlinx.coroutines.test.runTest 31 | 32 | class TurbineTest { 33 | @Test 34 | fun exceptionsPropagateWhenExpectMostRecentItem() = runTest { 35 | val expected = CustomThrowable("hello") 36 | 37 | val actual = assertFailsWith { 38 | val channel = Turbine() 39 | 40 | channel.add(1) 41 | channel.add(2) 42 | channel.add(3) 43 | 44 | channel.close(expected) 45 | 46 | channel.expectMostRecentItem() 47 | } 48 | assertSame(expected, actual) 49 | } 50 | 51 | @Test 52 | fun expectMostRecentItemButNoItemWasFoundThrows() = runTest { 53 | val actual = assertFailsWith { 54 | Turbine().expectMostRecentItem() 55 | } 56 | assertEquals("No item was found", actual.message) 57 | } 58 | 59 | @Test 60 | fun expectMostRecentItem() = runTest { 61 | val onTwoSent = CompletableDeferred() 62 | val onTwoContinue = CompletableDeferred() 63 | val onCompleteSent = CompletableDeferred() 64 | val onCompleteContinue = CompletableDeferred() 65 | 66 | val channel = Turbine() 67 | 68 | launch { 69 | listOf(1, 2, 3, 4, 5).forEach { 70 | if (it == 3) { 71 | onTwoSent.complete(Unit) 72 | onTwoContinue.await() 73 | } 74 | channel.add(it) 75 | } 76 | 77 | onCompleteSent.complete(Unit) 78 | onCompleteContinue.await() 79 | } 80 | 81 | onTwoSent.await() 82 | assertEquals(2, channel.expectMostRecentItem()) 83 | onTwoContinue.complete(Unit) 84 | 85 | onCompleteSent.await() 86 | assertEquals(5, channel.expectMostRecentItem()) 87 | onCompleteContinue.complete(Unit) 88 | } 89 | 90 | @Test 91 | fun assertNullValuesWithExpectMostRecentItem() = runTest { 92 | val channel = Turbine() 93 | 94 | listOf(1, 2, null).forEach { channel.add(it) } 95 | 96 | assertEquals(null, channel.expectMostRecentItem()) 97 | } 98 | 99 | @Test 100 | fun awaitItemsAreSkipped() = runTest { 101 | val channel = Turbine() 102 | listOf(1, 2, 3).forEach { channel.add(it) } 103 | 104 | channel.skipItems(2) 105 | assertEquals(3, channel.awaitItem()) 106 | } 107 | 108 | @Test 109 | fun expectErrorOnCompletionBeforeAllItemsWereSkipped() = runTest { 110 | val channel = Turbine() 111 | channel.add(1) 112 | channel.close() 113 | assertFailsWith { 114 | channel.skipItems(2) 115 | } 116 | } 117 | 118 | @Test 119 | fun expectErrorOnErrorReceivedBeforeAllItemsWereSkipped() = runTest { 120 | val error = CustomThrowable("hello") 121 | val channel = Turbine() 122 | channel.add(1) 123 | channel.close(error) 124 | val actual = assertFailsWith { 125 | channel.skipItems(2) 126 | } 127 | assertSame(error, actual.cause) 128 | } 129 | 130 | @Test 131 | fun expectNoEvents() = runTest { 132 | Turbine().expectNoEvents() 133 | } 134 | 135 | @Test 136 | fun awaitItemEvent() = runTest { 137 | val item = Any() 138 | val channel = Turbine() 139 | channel.add(item) 140 | val event = channel.awaitEvent() 141 | assertEquals(Event.Item(item), event) 142 | } 143 | 144 | @Test 145 | fun expectCompleteEvent() = runTest { 146 | val channel = Turbine() 147 | channel.close() 148 | val event = channel.awaitEvent() 149 | assertEquals(Event.Complete, event) 150 | } 151 | 152 | @Test 153 | fun expectErrorEvent() = runTest { 154 | val exception = CustomThrowable("hello") 155 | val channel = Turbine() 156 | channel.close(exception) 157 | val event = channel.awaitEvent() 158 | assertEquals(Event.Error(exception), event) 159 | } 160 | 161 | @Test 162 | fun awaitItem() = runTest { 163 | val item = Any() 164 | val channel = Turbine() 165 | channel.add(item) 166 | channel.add(null) 167 | assertSame(item, channel.awaitItem()) 168 | assertNull(channel.awaitItem()) 169 | } 170 | 171 | @Test 172 | fun awaitItemButWasCloseThrows() = runTest { 173 | val actual = assertFailsWith { 174 | val channel = Turbine() 175 | channel.close() 176 | channel.awaitItem() 177 | } 178 | assertEquals("Expected item but found Complete", actual.message) 179 | } 180 | 181 | @Test 182 | fun awaitItemButWasErrorThrows() = runTest { 183 | val error = CustomThrowable("hello") 184 | val actual = assertFailsWith { 185 | val channel = Turbine() 186 | channel.close(error) 187 | channel.awaitItem() 188 | } 189 | assertEquals("Expected item but found Error(CustomThrowable)", actual.message) 190 | assertSame(error, actual.cause) 191 | } 192 | 193 | @Test 194 | fun awaitComplete() = runTest { 195 | val channel = Turbine() 196 | channel.close() 197 | channel.awaitComplete() 198 | } 199 | 200 | @Test 201 | fun awaitCompleteButWasItemThrows() = runTest { 202 | val actual = assertFailsWith { 203 | val channel = Turbine() 204 | channel.add("item!") 205 | channel.awaitComplete() 206 | } 207 | assertEquals("Expected complete but found Item(item!)", actual.message) 208 | } 209 | 210 | @Test 211 | fun awaitCompleteButWasErrorThrows() = runTest { 212 | val actual = assertFailsWith { 213 | val channel = Turbine() 214 | channel.close(RuntimeException()) 215 | channel.awaitComplete() 216 | } 217 | assertEquals("Expected complete but found Error(RuntimeException)", actual.message) 218 | } 219 | 220 | @Test 221 | fun awaitError() = runTest { 222 | val error = CustomThrowable("hello") 223 | val channel = Turbine() 224 | channel.close(error) 225 | assertSame(error, channel.awaitError()) 226 | } 227 | 228 | @Test 229 | fun awaitErrorButWasItemThrows() = runTest { 230 | val actual = assertFailsWith { 231 | val channel = Turbine() 232 | channel.add("item!") 233 | channel.awaitError() 234 | } 235 | assertEquals("Expected error but found Item(item!)", actual.message) 236 | } 237 | 238 | @Test 239 | fun awaitErrorButWasCompleteThrows() = runTest { 240 | val actual = assertFailsWith { 241 | val channel = Turbine() 242 | channel.close() 243 | channel.awaitError() 244 | } 245 | assertEquals("Expected error but found Complete", actual.message) 246 | } 247 | 248 | @Test 249 | fun takeItem() = withTestScope { 250 | val item = Any() 251 | val channel = Turbine() 252 | channel.add(item) 253 | channel.add(null) 254 | assertSame(item, channel.takeItem()) 255 | assertNull(channel.takeItem()) 256 | } 257 | 258 | @Test 259 | fun takeItemButWasCloseThrows() = withTestScope { 260 | val actual = assertFailsWith { 261 | val channel = Turbine() 262 | // JS 263 | CoroutineScope(Dispatchers.Default).launch(start = CoroutineStart.UNDISPATCHED) { 264 | channel.close() 265 | } 266 | 267 | channel.takeItem() 268 | } 269 | assertEquals("Expected item but found Complete", actual.message) 270 | } 271 | 272 | @Test 273 | fun takeItemButWasErrorThrows() = withTestScope { 274 | val error = CustomThrowable("hello") 275 | val actual = assertFailsWith { 276 | val channel = Turbine() 277 | // JS 278 | CoroutineScope(Dispatchers.Default).launch(start = CoroutineStart.UNDISPATCHED) { 279 | channel.close(error) 280 | } 281 | channel.takeItem() 282 | } 283 | assertEquals("Expected item but found Error(CustomThrowable)", actual.message) 284 | assertSame(error, actual.cause) 285 | } 286 | 287 | @Test 288 | fun expectMostRecentItemButNoItemWasFoundThrowsWithName() = runTest { 289 | val actual = assertFailsWith { 290 | Turbine(name = "empty turbine").expectMostRecentItem() 291 | } 292 | assertEquals("No item was found for empty turbine", actual.message) 293 | } 294 | 295 | @Test 296 | fun awaitItemButWasCloseThrowsWithName() = runTest { 297 | val actual = assertFailsWith { 298 | val channel = Turbine(name = "closed turbine") 299 | channel.close() 300 | channel.awaitItem() 301 | } 302 | assertEquals("Expected item for closed turbine but found Complete", actual.message) 303 | } 304 | 305 | @Test 306 | fun awaitCompleteButWasItemThrowsWithName() = runTest { 307 | val actual = assertFailsWith { 308 | val channel = Turbine(name = "item turbine") 309 | channel.add("item!") 310 | channel.awaitComplete() 311 | } 312 | assertEquals("Expected complete for item turbine but found Item(item!)", actual.message) 313 | } 314 | 315 | @Test 316 | fun awaitErrorButWasItemThrowsWithName() = runTest { 317 | val actual = assertFailsWith { 318 | val channel = Turbine(name = "item turbine") 319 | channel.add("item!") 320 | channel.awaitError() 321 | } 322 | assertEquals("Expected error for item turbine but found Item(item!)", actual.message) 323 | } 324 | 325 | @Test 326 | fun takeItemButWasCloseThrowsWithName() = withTestScope { 327 | val actual = assertFailsWith { 328 | val channel = Turbine(name = "closed turbine") 329 | // JS 330 | CoroutineScope(Dispatchers.Default).launch(start = CoroutineStart.UNDISPATCHED) { 331 | channel.close() 332 | } 333 | 334 | channel.takeItem() 335 | } 336 | assertEquals("Expected item for closed turbine but found Complete", actual.message) 337 | } 338 | 339 | @Test fun skipItemsThrowsOnCompleteWithName() = runTest { 340 | val channel = Turbine(name = "two item channel") 341 | channel.add(1) 342 | channel.add(2) 343 | channel.close() 344 | val message = assertFailsWith { 345 | channel.skipItems(3) 346 | }.message 347 | 348 | assertEquals("Expected 3 items for two item channel but got 2 items and Complete", message) 349 | } 350 | 351 | /** 352 | * Used to run test code with a [TestScope], but still outside a suspending context. 353 | */ 354 | private fun withTestScope(block: TestScope.() -> Unit) { 355 | val job = Job() 356 | 357 | TestScope(job).block() 358 | 359 | job.cancel() 360 | } 361 | } 362 | -------------------------------------------------------------------------------- /src/commonTest/kotlin/app/cash/turbine/testUtil.common.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2020 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.turbine 17 | 18 | import kotlinx.coroutines.awaitCancellation 19 | import kotlinx.coroutines.channels.Channel 20 | import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED 21 | import kotlinx.coroutines.channels.ReceiveChannel 22 | import kotlinx.coroutines.flow.Flow 23 | import kotlinx.coroutines.flow.flow 24 | 25 | /** A flow that never emits anything. */ 26 | fun neverFlow(): Flow = flow { awaitCancellation() } 27 | 28 | /** 29 | * Given a library entry point, ensure that the preceding stack frame is 30 | * the expected call site. 31 | * 32 | * Only works on the JVM. 33 | */ 34 | expect fun assertCallSitePresentInStackTraceOnJvm( 35 | throwable: Throwable, 36 | entryPoint: String, 37 | callSite: String, 38 | ) 39 | 40 | fun channelOf(vararg items: T, closeCause: Throwable? = null): ReceiveChannel { 41 | return Channel(UNLIMITED).also { channel -> 42 | for (item in items) { 43 | channel.trySend(item).getOrThrow() 44 | } 45 | channel.close(closeCause) 46 | } 47 | } 48 | 49 | fun emptyChannel(): ReceiveChannel = channelOf() 50 | fun neverChannel(): ReceiveChannel = Channel() 51 | -------------------------------------------------------------------------------- /src/jvmTest/kotlin/app/cash/turbine/ChannelJvmTest.kt: -------------------------------------------------------------------------------- 1 | package app.cash.turbine 2 | 3 | import kotlin.test.assertEquals 4 | import kotlin.test.assertFailsWith 5 | import kotlinx.coroutines.test.runTest 6 | import org.junit.Test 7 | 8 | class ChannelJvmTest { 9 | @Test 10 | fun takeItemSuspendingThrows() = runTest { 11 | val actual = assertFailsWith { 12 | emptyChannel().takeItem() 13 | } 14 | assertEquals("Calling context is suspending; use a suspending method instead", actual.message) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/jvmTest/kotlin/app/cash/turbine/TurbineJvmTest.kt: -------------------------------------------------------------------------------- 1 | package app.cash.turbine 2 | 3 | import kotlin.test.assertEquals 4 | import kotlin.test.assertFailsWith 5 | import kotlinx.coroutines.test.runTest 6 | import org.junit.Test 7 | 8 | class TurbineJvmTest { 9 | @Test 10 | fun takeItemSuspendingThrows() = runTest { 11 | val actual = assertFailsWith { 12 | val channel = Turbine() 13 | channel.cancel() 14 | channel.takeItem() 15 | } 16 | assertEquals("Calling context is suspending; use a suspending method instead", actual.message) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/jvmTest/kotlin/app/cash/turbine/testUtil.kt: -------------------------------------------------------------------------------- 1 | package app.cash.turbine 2 | 3 | actual fun assertCallSitePresentInStackTraceOnJvm( 4 | throwable: Throwable, 5 | entryPoint: String, 6 | callSite: String, 7 | ) { 8 | val lines = throwable.stackTraceToString().lines() 9 | 10 | val awaitItemIndex = lines.indexOfFirst { entryPoint in it } 11 | if (awaitItemIndex == -1) { 12 | throw AssertionError("'$entryPoint' not found in stacktrace\n\n${lines.joinToString("\n")}") 13 | } 14 | 15 | if (callSite !in lines[awaitItemIndex + 1]) { 16 | throw AssertionError( 17 | "Expected '$callSite' immediately precede '$entryPoint', but it did not\n\n${lines.joinToString("\n")}", 18 | ) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/nonJvmTest/kotlin/app/cash/turbine/testUtil.kt: -------------------------------------------------------------------------------- 1 | package app.cash.turbine 2 | 3 | actual fun assertCallSitePresentInStackTraceOnJvm( 4 | throwable: Throwable, 5 | entryPoint: String, 6 | callSite: String, 7 | ) { 8 | // Do nothing :( 9 | } 10 | --------------------------------------------------------------------------------