diff --git a/.github/actions/gradle-task-with-commit/action.yml b/.github/actions/gradle-task-with-commit/action.yml index 7ee090be9..d1ddc76a2 100644 --- a/.github/actions/gradle-task-with-commit/action.yml +++ b/.github/actions/gradle-task-with-commit/action.yml @@ -17,6 +17,8 @@ inputs: commit-message: description: 'The commit message to use if changes are generated' default: '' + access-token: + description: 'The access token to use for checkouts.' restore-cache-key: description: 'The unique identifier for the associated cache. Any other consumers or producers for this cache must use the same name.' default: 'null' @@ -31,7 +33,9 @@ runs: id: can-push shell: bash run: | - if [[ "${{ env.GITHUB_REF_PROTECTED }}" == 'true' ]]; then + if [[ "${{ inputs.access-token }}" == '' ]]; then + echo "can_push=false" >> $GITHUB_OUTPUT + elif [[ "${{ env.GITHUB_REF_PROTECTED }}" == 'true' ]]; then echo "can_push=false" >> $GITHUB_OUTPUT elif [[ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ]]; then echo "can_push=false" >> $GITHUB_OUTPUT @@ -40,10 +44,11 @@ runs: fi # ensure that we have the actual branch checked out. By default, actions/checkout is headless. - - name: check out with PAT + - name: check out with the generated app token uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 if: steps.can-push.outputs.can_push == 'true' with: + token: ${{ inputs.access-token }} ref: ${{ github.head_ref }} fetch-depth: 0 @@ -70,7 +75,7 @@ runs: - name: commit ${{ inputs.fix-task }} changes if: steps.can-push.outputs.can_push == 'true' - uses: stefanzweifel/git-auto-commit-action@8756aa072ef5b4a080af5dc8fef36c5d586e521d # v5 + uses: stefanzweifel/git-auto-commit-action@v6 with: commit_message: ${{ steps.set-commit-message.outputs.commit-message }} commit_options: '--no-verify --signoff' diff --git a/.github/workflows/kotlin.yml b/.github/workflows/kotlin.yml index 2d056f98b..2457beed8 100644 --- a/.github/workflows/kotlin.yml +++ b/.github/workflows/kotlin.yml @@ -1,224 +1,290 @@ -name : Kotlin CI +name: Kotlin CI -on : - pull_request : - merge_group : +on: + pull_request: + merge_group: env: ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: 720 # 12 minutes; default is 20s # If CI is already running for a branch when that branch is updated, cancel the older jobs. -concurrency : - group : ci-${{ github.ref }}-${{ github.head_ref }} - cancel-in-progress : true +concurrency: + group: ci-${{ github.ref }}-${{ github.head_ref }} + cancel-in-progress: true -jobs : +jobs: - build-all : - name : Build all - runs-on : workflow-kotlin-test-runner-ubuntu-4core - steps : + build-all: + name: Build all + runs-on: workflow-kotlin-test-runner-ubuntu-4core + steps: - name: Checkout uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - - name : main build - uses : ./.github/actions/gradle-task - with : - task : compileKotlin assembleDebug - write-cache-key : main-build-artifacts - - dokka : - name : Dokka - runs-on : ubuntu-latest - needs : build-all - steps : + - name: main build + uses: ./.github/actions/gradle-task + with: + task: compileKotlin assembleDebug + write-cache-key: main-build-artifacts + + dokka: + name: Dokka + runs-on: ubuntu-latest + needs: build-all + steps: - name: Checkout uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - - name : Run dokka to validate kdoc - uses : ./.github/actions/gradle-task - with : - task : siteDokka - write-cache-key : main-build-artifacts + - name: Run dokka to validate kdoc + uses: ./.github/actions/gradle-task + with: + task: siteDokka + write-cache-key: main-build-artifacts - shards-and-version : - name : Shard Matrix Yaml - runs-on : workflow-kotlin-test-runner-ubuntu-4core - steps : + shards-and-version: + name: Shard Matrix Yaml + runs-on: workflow-kotlin-test-runner-ubuntu-4core + steps: - name: Checkout uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + # We use the workflow-pr-fixer app to authenticate and get a token that will cause the workflow + # to be triggered again. + - name: Generate App Token + uses: actions/create-github-app-token@v2 + id: app-token + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} - - name : check published artifacts - uses : ./.github/actions/gradle-task-with-commit - with : - check-task : connectedCheckShardMatrixYamlCheck checkVersionIsSnapshot - fix-task : connectedCheckShardMatrixYamlUpdate checkVersionIsSnapshot - write-cache-key : build-logic + - name: check published artifacts + uses: ./.github/actions/gradle-task-with-commit + with: + check-task: connectedCheckShardMatrixYamlCheck checkVersionIsSnapshot + fix-task: connectedCheckShardMatrixYamlUpdate checkVersionIsSnapshot + write-cache-key: build-logic + access-token: ${{ steps.app-token.outputs.token }} - artifacts-check : - name : ArtifactsCheck + artifacts-check: + name: ArtifactsCheck # the `artifactsCheck` task has to run on macOS in order to see the iOS KMP artifacts - runs-on : macos-latest - steps : + runs-on: macos-latest + steps: - name: Checkout uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + # We use the workflow-pr-fixer app to authenticate and get a token that will cause the workflow + # to be triggered again. + - name: Generate App Token + uses: actions/create-github-app-token@v2 + id: app-token + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: check published artifacts + uses: ./.github/actions/gradle-task-with-commit + with: + check-task: artifactsCheck + fix-task: artifactsDump + write-cache-key: build-logic + access-token: ${{ steps.app-token.outputs.token }} - - name : check published artifacts - uses : ./.github/actions/gradle-task-with-commit - with : - check-task : artifactsCheck - fix-task : artifactsDump - write-cache-key : build-logic - - dependency-guard : - name : Dependency Guard - runs-on : ubuntu-latest - steps : + dependency-guard: + name: Dependency Guard + runs-on: ubuntu-latest + steps: - name: Checkout uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + # We use the workflow-pr-fixer app to authenticate and get a token that will cause the workflow + # to be triggered again. + - name: Generate App Token + uses: actions/create-github-app-token@v2 + id: app-token + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} # If the PR was made by a maintainer or Renovate, automatically update baselines and push # so that no one has to check out the branch and update the baselines manually. - - name : dependency-guard - uses : ./.github/actions/gradle-task-with-commit - with : - check-task : dependencyGuard --refresh-dependencies - fix-task : dependencyGuardBaseline --refresh-dependencies - write-cache-key : build-logic - - ktlint : - name : KtLint - runs-on : ubuntu-latest - steps : + - name: dependency-guard + uses: ./.github/actions/gradle-task-with-commit + with: + check-task: dependencyGuard --refresh-dependencies + fix-task: dependencyGuardBaseline --refresh-dependencies + write-cache-key: build-logic + access-token: ${{ steps.app-token.outputs.token }} + + ktlint: + name: KtLint + runs-on: ubuntu-latest + steps: - name: Checkout uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + # We use the workflow-pr-fixer app to authenticate and get a token that will cause the workflow + # to be triggered again. + - name: Generate App Token + uses: actions/create-github-app-token@v2 + id: app-token + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} # If the PR was made by a maintainer or Renovate, automatically format and push # so that no one has to check out the branch and do it manually. - - name : KtLint - uses : ./.github/actions/gradle-task-with-commit - with : - check-task : ktLintCheck - fix-task : ktLintFormat - write-cache-key : build-logic - - api-check : - name : Api check - runs-on : ubuntu-latest - steps : + - name: KtLint + uses: ./.github/actions/gradle-task-with-commit + with: + check-task: ktLintCheck + fix-task: ktLintFormat + write-cache-key: build-logic + access-token: ${{ steps.app-token.outputs.token }} + + api-check: + name: Api check + runs-on: ubuntu-latest + steps: - name: Checkout uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + # We use the workflow-pr-fixer app to authenticate and get a token that will cause the workflow + # to be triggered again. + - name: Generate App Token + uses: actions/create-github-app-token@v2 + id: app-token + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} # If the PR was made by a maintainer or Renovate, automatically format and push # so that no one has to check out the branch and do it manually. - - name : binary compatibility - uses : ./.github/actions/gradle-task-with-commit - with : - check-task : apiCheck - fix-task : apiDump - write-cache-key : build-logic - - android-lint : - name : Android Lint - runs-on : ubuntu-latest - needs : build-all - timeout-minutes : 20 - steps : + - name: binary compatibility + uses: ./.github/actions/gradle-task-with-commit + with: + check-task: apiCheck + fix-task: apiDump + write-cache-key: build-logic + access-token: ${{ steps.app-token.outputs.token }} + + android-lint: + name: Android Lint + runs-on: ubuntu-latest + needs: build-all + timeout-minutes: 20 + steps: - name: Checkout uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - - name : Check with Gradle - uses : ./.github/actions/gradle-task - with : - task : lint - write-cache-key : main-build-artifacts - - check : - name : Check - runs-on : macos-latest - needs : build-all - timeout-minutes : 20 - steps : + - name: Check with Gradle + uses: ./.github/actions/gradle-task + with: + task: lint + write-cache-key: main-build-artifacts + + check: + name: Check + runs-on: macos-latest + needs: build-all + timeout-minutes: 20 + steps: - name: Checkout uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - - name : Check with Gradle - uses : ./.github/actions/gradle-task - with : - task : | + - name: Check with Gradle + uses: ./.github/actions/gradle-task + with: + task: | allTests test --continue - restore-cache-key : build-logic - write-cache-key : main-build-artifacts + restore-cache-key: build-logic + write-cache-key: main-build-artifacts # Report as GitHub Pull Request Check. - - name : Publish Test Report - uses : mikepenz/action-junit-report@5f47764eec0e1c1f19f40c8e60a5ba47e47015c5 # v4 - if : always() # always run even if the previous step fails - with : - report_paths : '**/build/test-results/test/TEST-*.xml' - - tutorials : - name : Build Tutorials - runs-on : ubuntu-latest - timeout-minutes : 15 - steps : + - name: Publish Test Report + uses: mikepenz/action-junit-report@5f47764eec0e1c1f19f40c8e60a5ba47e47015c5 # v4 + if: always() # always run even if the previous step fails + with: + report_paths: '**/build/test-results/test/TEST-*.xml' + + tutorials: + name: Build Tutorials + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: # These setup steps should be common across all jobs in this workflow. - name: Checkout uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - - name : build tutorials - uses : ./.github/actions/gradle-task - with : - task : build - build-root-directory : samples/tutorial - restore-cache-key : main-build-artifacts - - jvm-conflate-runtime-test : - name : Conflate Stale Renderings Runtime JVM Tests - runs-on : ubuntu-latest - timeout-minutes : 20 - steps : + - name: build tutorials + uses: ./.github/actions/gradle-task + with: + task: build + build-root-directory: samples/tutorial + restore-cache-key: main-build-artifacts + + jvm-drainExclusive-runtime-test: + name: DEA JVM Tests + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: - name: Checkout uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - - name : Check with Gradle - uses : ./.github/actions/gradle-task - with : - task : jvmTest --continue -Pworkflow.runtime=conflate - restore-cache-key : main-build-artifacts + - name: Check with Gradle + uses: ./.github/actions/gradle-task + with: + task: jvmTest --continue -Pworkflow.runtime=drainExclusive + restore-cache-key: main-build-artifacts # Report as GitHub Pull Request Check. - - name : Publish Test Report - uses : mikepenz/action-junit-report@5f47764eec0e1c1f19f40c8e60a5ba47e47015c5 # v4 - if : always() # always run even if the previous step fails - with : - report_paths : '**/build/test-results/test/TEST-*.xml' - - jvm-stateChange-runtime-test : - name : Render On State Change Only Runtime JVM Tests - runs-on : ubuntu-latest - timeout-minutes : 20 - steps : + - name: Publish Test Report + uses: mikepenz/action-junit-report@5f47764eec0e1c1f19f40c8e60a5ba47e47015c5 # v4 + if: always() # always run even if the previous step fails + with: + report_paths: '**/build/test-results/test/TEST-*.xml' + + jvm-conflate-runtime-test: + name: CSR JVM Tests + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + + - name: Check with Gradle + uses: ./.github/actions/gradle-task + with: + task: jvmTest --continue -Pworkflow.runtime=conflate + restore-cache-key: main-build-artifacts + + # Report as GitHub Pull Request Check. + - name: Publish Test Report + uses: mikepenz/action-junit-report@5f47764eec0e1c1f19f40c8e60a5ba47e47015c5 # v4 + if: always() # always run even if the previous step fails + with: + report_paths: '**/build/test-results/test/TEST-*.xml' + + jvm-stateChange-runtime-test: + name: SCO JVM Tests + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: - name: Checkout uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - - name : Check with Gradle - uses : ./.github/actions/gradle-task - with : - task : jvmTest --continue -Pworkflow.runtime=stateChange - restore-cache-key : main-build-artifacts + - name: Check with Gradle + uses: ./.github/actions/gradle-task + with: + task: jvmTest --continue -Pworkflow.runtime=stateChange + restore-cache-key: main-build-artifacts # Report as GitHub Pull Request Check. - - name : Publish Test Report - uses : mikepenz/action-junit-report@5f47764eec0e1c1f19f40c8e60a5ba47e47015c5 # v4 - if : always() # always run even if the previous step fails - with : - report_paths : '**/build/test-results/test/TEST-*.xml' + - name: Publish Test Report + uses: mikepenz/action-junit-report@5f47764eec0e1c1f19f40c8e60a5ba47e47015c5 # v4 + if: always() # always run even if the previous step fails + with: + report_paths: '**/build/test-results/test/TEST-*.xml' jvm-stable-handlers-test: - name: Stable Event Handlers Only Runtime JVM Tests + name: SEH JVM Tests runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -239,7 +305,7 @@ jobs : report_paths: '**/build/test-results/test/TEST-*.xml' jvm-partial-runtime-test: - name: Partial Tree Rendering Only Runtime JVM Tests + name: PTR JVM Tests runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -259,29 +325,29 @@ jobs : with: report_paths: '**/build/test-results/test/TEST-*.xml' - jvm-conflate-stateChange-runtime-test : - name : Render On State Change Only and Conflate Stale Runtime JVM Tests - runs-on : ubuntu-latest - timeout-minutes : 20 - steps : + jvm-conflate-stateChange-runtime-test: + name: SCO, CSR JVM Tests + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: - name: Checkout uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - - name : Check with Gradle - uses : ./.github/actions/gradle-task - with : - task : jvmTest --continue -Pworkflow.runtime=conflate-stateChange - restore-cache-key : main-build-artifacts + - name: Check with Gradle + uses: ./.github/actions/gradle-task + with: + task: jvmTest --continue -Pworkflow.runtime=conflate-stateChange + restore-cache-key: main-build-artifacts # Report as GitHub Pull Request Check. - - name : Publish Test Report - uses : mikepenz/action-junit-report@5f47764eec0e1c1f19f40c8e60a5ba47e47015c5 # v4 - if : always() # always run even if the previous step fails - with : - report_paths : '**/build/test-results/test/TEST-*.xml' + - name: Publish Test Report + uses: mikepenz/action-junit-report@5f47764eec0e1c1f19f40c8e60a5ba47e47015c5 # v4 + if: always() # always run even if the previous step fails + with: + report_paths: '**/build/test-results/test/TEST-*.xml' jvm-conflate-partial-runtime-test: - name: Render On State Change Only and Conflate Stale Runtime JVM Tests + name: CSR, PTR JVM Tests runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -301,131 +367,236 @@ jobs : with: report_paths: '**/build/test-results/test/TEST-*.xml' - ios-tests : - name : iOS Tests - runs-on : macos-latest - timeout-minutes : 30 - steps : + jvm-conflate-drainExclusive-runtime-test: + name: CSR, DEA JVM Tests + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + + - name: Check with Gradle + uses: ./.github/actions/gradle-task + with: + task: jvmTest --continue -Pworkflow.runtime=conflate-drainExclusive + restore-cache-key: main-build-artifacts + + # Report as GitHub Pull Request Check. + - name: Publish Test Report + uses: mikepenz/action-junit-report@5f47764eec0e1c1f19f40c8e60a5ba47e47015c5 # v4 + if: always() # always run even if the previous step fails + with: + report_paths: '**/build/test-results/test/TEST-*.xml' + + jvm-stateChange-drainExclusive-runtime-test: + name: SCO, DEA JVM Tests + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: - name: Checkout uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - - name : Check with Gradle - uses : ./.github/actions/gradle-task - with : - task : iosX64Test - restore-cache-key : main-build-artifacts + - name: Check with Gradle + uses: ./.github/actions/gradle-task + with: + task: jvmTest --continue -Pworkflow.runtime=stateChange-drainExclusive + restore-cache-key: main-build-artifacts # Report as GitHub Pull Request Check. - - name : Publish Test Report - uses : mikepenz/action-junit-report@5f47764eec0e1c1f19f40c8e60a5ba47e47015c5 # v4 - if : always() # always run even if the previous step fails - with : - report_paths : '**/build/test-results/test/TEST-*.xml' - - js-tests : - name : JS Tests - runs-on : macos-latest - timeout-minutes : 30 - steps : + - name: Publish Test Report + uses: mikepenz/action-junit-report@5f47764eec0e1c1f19f40c8e60a5ba47e47015c5 # v4 + if: always() # always run even if the previous step fails + with: + report_paths: '**/build/test-results/test/TEST-*.xml' + + jvm-partial-drainExclusive-runtime-test: + name: PTR, DEA JVM Tests + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + + - name: Check with Gradle + uses: ./.github/actions/gradle-task + with: + task: jvmTest --continue -Pworkflow.runtime=partial-drainExclusive + restore-cache-key: main-build-artifacts + + # Report as GitHub Pull Request Check. + - name: Publish Test Report + uses: mikepenz/action-junit-report@5f47764eec0e1c1f19f40c8e60a5ba47e47015c5 # v4 + if: always() # always run even if the previous step fails + with: + report_paths: '**/build/test-results/test/TEST-*.xml' + + jvm-conflate-stateChange-drainExclusive-runtime-test: + name: SCO, CSR, DEA JVM Tests + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + + - name: Check with Gradle + uses: ./.github/actions/gradle-task + with: + task: jvmTest --continue -Pworkflow.runtime=conflate-stateChange-drainExclusive + restore-cache-key: main-build-artifacts + + # Report as GitHub Pull Request Check. + - name: Publish Test Report + uses: mikepenz/action-junit-report@5f47764eec0e1c1f19f40c8e60a5ba47e47015c5 # v4 + if: always() # always run even if the previous step fails + with: + report_paths: '**/build/test-results/test/TEST-*.xml' + + jvm-conflate-partial-drainExclusive-runtime-test: + name: CSR, PTR, DEA JVM Tests + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + + - name: Check with Gradle + uses: ./.github/actions/gradle-task + with: + task: jvmTest --continue -Pworkflow.runtime=conflate-partial-drainExclusive + restore-cache-key: main-build-artifacts + + # Report as GitHub Pull Request Check. + - name: Publish Test Report + uses: mikepenz/action-junit-report@5f47764eec0e1c1f19f40c8e60a5ba47e47015c5 # v4 + if: always() # always run even if the previous step fails + with: + report_paths: '**/build/test-results/test/TEST-*.xml' + + ios-tests: + name: iOS Tests + runs-on: macos-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + + - name: Check with Gradle + uses: ./.github/actions/gradle-task + with: + task: iosX64Test + restore-cache-key: main-build-artifacts + + # Report as GitHub Pull Request Check. + - name: Publish Test Report + uses: mikepenz/action-junit-report@5f47764eec0e1c1f19f40c8e60a5ba47e47015c5 # v4 + if: always() # always run even if the previous step fails + with: + report_paths: '**/build/test-results/test/TEST-*.xml' + + js-tests: + name: JS Tests + runs-on: macos-latest + timeout-minutes: 30 + steps: - name: Checkout uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 ## JS Specific Tests (for KMP js actuals in core and runtime). - - name : Check with Gradle - uses : ./.github/actions/gradle-task - with : - task : jsTest - restore-cache-key : main-build-artifacts + - name: Check with Gradle + uses: ./.github/actions/gradle-task + with: + task: jsTest + restore-cache-key: main-build-artifacts # Report as GitHub Pull Request Check. - - name : Publish Test Report - uses : mikepenz/action-junit-report@5f47764eec0e1c1f19f40c8e60a5ba47e47015c5 # v4 - if : always() # always run even if the previous step fails - with : - report_paths : '**/build/test-results/test/TEST-*.xml' - - performance-tests : - name : Performance tests - runs-on : workflow-kotlin-test-runner-ubuntu-4core - timeout-minutes : 45 - strategy : + - name: Publish Test Report + uses: mikepenz/action-junit-report@5f47764eec0e1c1f19f40c8e60a5ba47e47015c5 # v4 + if: always() # always run even if the previous step fails + with: + report_paths: '**/build/test-results/test/TEST-*.xml' + + performance-tests: + name: Performance tests + runs-on: workflow-kotlin-test-runner-ubuntu-4core + timeout-minutes: 45 + strategy: # Allow tests to continue on other devices if they fail on one device. - fail-fast : false - matrix : - api-level : + fail-fast: false + matrix: + api-level: - 31 - steps : + steps: - name: Checkout uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - - name : Instrumented tests - uses : ./.github/actions/gradle-tasks-with-emulator - with : - tests-name : perf-tests-results - api-level : ${{ matrix.api-level }} - prepare-task : :benchmarks:performance-poetry:complex-poetry:prepareDebugAndroidTestArtifacts - test-task : :benchmarks:performance-poetry:complex-poetry:connectedCheck --continue - restore-cache-key : androidTest-build-artifacts - - instrumentation-tests : - name : Instrumentation tests - runs-on : workflow-kotlin-test-runner-ubuntu-4core - timeout-minutes : 60 - strategy : + - name: Instrumented tests + uses: ./.github/actions/gradle-tasks-with-emulator + with: + tests-name: perf-tests-results + api-level: ${{ matrix.api-level }} + prepare-task: :benchmarks:performance-poetry:complex-poetry:prepareDebugAndroidTestArtifacts + test-task: :benchmarks:performance-poetry:complex-poetry:connectedCheck --continue + restore-cache-key: androidTest-build-artifacts + + instrumentation-tests: + name: Instrumentation tests + runs-on: workflow-kotlin-test-runner-ubuntu-4core + timeout-minutes: 60 + strategy: # Allow tests to continue on other devices if they fail on one device. - fail-fast : false - matrix : - api-level : + fail-fast: false + matrix: + api-level: - 31 ### shardNum: [ 1, 2, 3 ] ### - steps : + steps: - name: Checkout uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - - name : Instrumented tests - uses : ./.github/actions/gradle-tasks-with-emulator - with : - tests-name : core-tests-results-${{matrix.shardNum}} - api-level : ${{ matrix.api-level }} - prepare-task : prepareConnectedCheckShard${{matrix.shardNum}} - test-task : connectedCheckShard${{matrix.shardNum}} -x :benchmarks:dungeon-benchmark:connectedCheck -x :benchmarks:performance-poetry:complex-benchmark:connectedCheck -x :benchmarks:performance-poetry:complex-poetry:connectedCheck - write-cache-key : androidTest-build-artifacts-${{matrix.shardNum}} - restore-cache-key : main-build-artifacts - - runtime-instrumentation-tests : - name : Alternate Runtime Instrumentation tests - runs-on : workflow-kotlin-test-runner-ubuntu-4core - timeout-minutes : 60 - strategy : + - name: Instrumented tests + uses: ./.github/actions/gradle-tasks-with-emulator + with: + tests-name: core-tests-results-${{matrix.shardNum}} + api-level: ${{ matrix.api-level }} + prepare-task: prepareConnectedCheckShard${{matrix.shardNum}} + test-task: connectedCheckShard${{matrix.shardNum}} -x :benchmarks:dungeon-benchmark:connectedCheck -x :benchmarks:performance-poetry:complex-benchmark:connectedCheck -x :benchmarks:performance-poetry:complex-poetry:connectedCheck + write-cache-key: androidTest-build-artifacts-${{matrix.shardNum}} + restore-cache-key: main-build-artifacts + + runtime-instrumentation-tests: + name: Alternate Runtime Instrumentation tests + runs-on: workflow-kotlin-test-runner-ubuntu-4core + timeout-minutes: 60 + strategy: # Allow tests to continue on other devices if they fail on one device. - fail-fast : false - matrix : - api-level : + fail-fast: false + matrix: + api-level: - 31 ### shardNum: [ 1, 2, 3 ] ### - runtime : [ conflate, stateChange, conflate-stateChange, partial, conflate-partial, stable ] - steps : + runtime: [ conflate, stateChange, drainExclusive, conflate-stateChange, partial, conflate-partial, stable, conflate-drainExclusive, stateChange-drainExclusive, partial-drainExclusive, conflate-partial-drainExclusive ] + steps: - name: Checkout uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - - name : Instrumented tests - uses : ./.github/actions/gradle-tasks-with-emulator - with : - tests-name : alt-runtime-tests-results-${{matrix.runtime}}-${{matrix.shardNum}} - api-level : ${{ matrix.api-level }} - prepare-task : prepareConnectedCheckShard${{matrix.shardNum}} -Pworkflow.runtime=${{matrix.runtime}} - test-task : connectedCheckShard${{matrix.shardNum}} -Pworkflow.runtime=${{matrix.runtime}} -x :benchmarks:dungeon-benchmark:connectedCheck -x :benchmarks:performance-poetry:complex-benchmark:connectedCheck -x :benchmarks:performance-poetry:complex-poetry:connectedCheck - write-cache-key : androidTest-build-artifacts-${{matrix.shardNum}}-${{matrix.runtime}} - restore-cache-key : main-build-artifacts - - all-green : - if : always() - runs-on : ubuntu-latest - needs : + - name: Instrumented tests + uses: ./.github/actions/gradle-tasks-with-emulator + with: + tests-name: alt-runtime-tests-results-${{matrix.runtime}}-${{matrix.shardNum}} + api-level: ${{ matrix.api-level }} + prepare-task: prepareConnectedCheckShard${{matrix.shardNum}} -Pworkflow.runtime=${{matrix.runtime}} + test-task: connectedCheckShard${{matrix.shardNum}} -Pworkflow.runtime=${{matrix.runtime}} -x :benchmarks:dungeon-benchmark:connectedCheck -x :benchmarks:performance-poetry:complex-benchmark:connectedCheck -x :benchmarks:performance-poetry:complex-poetry:connectedCheck + write-cache-key: androidTest-build-artifacts-${{matrix.shardNum}}-${{matrix.runtime}} + restore-cache-key: main-build-artifacts + + all-green: + if: always() + runs-on: ubuntu-latest + needs: - android-lint - api-check - artifacts-check @@ -435,20 +606,26 @@ jobs : - instrumentation-tests - ios-tests - js-tests + - jvm-drainExclusive-runtime-test - jvm-conflate-runtime-test - jvm-stateChange-runtime-test - jvm-stable-handlers-test - jvm-partial-runtime-test - jvm-conflate-stateChange-runtime-test - jvm-conflate-partial-runtime-test + - jvm-conflate-drainExclusive-runtime-test + - jvm-stateChange-drainExclusive-runtime-test + - jvm-partial-drainExclusive-runtime-test + - jvm-conflate-stateChange-drainExclusive-runtime-test + - jvm-conflate-partial-drainExclusive-runtime-test - ktlint - performance-tests - runtime-instrumentation-tests - shards-and-version - tutorials - steps : - - name : require that all other jobs have passed - uses : re-actors/alls-green@release/v1 - with : - jobs : ${{ toJSON(needs) }} + steps: + - name: require that all other jobs have passed + uses: re-actors/alls-green@release/v1 + with: + jobs: ${{ toJSON(needs) }} diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 2d53e318d..8bc5d7030 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -5,8 +5,7 @@ on: jobs: publish-release: - # macos-latest is too slow. -14 will become latest in Q2 '24 - runs-on: macos-14 + runs-on: macos-latest if: github.repository == 'square/workflow-kotlin' timeout-minutes: 45 @@ -34,6 +33,7 @@ jobs: with: task: publish env: - ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_NEXUS_USERNAME }} - ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_NEXUS_PASSWORD }} - ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.ARTIFACT_SIGNING_PRIVATE_KEY }} + ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_CENTRAL_USERNAME }} + ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_CENTRAL_PASSWORD }} + ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.GPG_SECRET_KEY }} + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.GPG_SECRET_PASSPHRASE }} diff --git a/.github/workflows/publish-snapshot.yml b/.github/workflows/publish-snapshot.yml index 783954cc2..6d3647a00 100644 --- a/.github/workflows/publish-snapshot.yml +++ b/.github/workflows/publish-snapshot.yml @@ -8,10 +8,9 @@ on: jobs: publish-snapshot: - # macos-latest is too slow. -14 will become latest in Q2 '24 - runs-on: macos-14 + runs-on: macos-latest if: github.repository == 'square/workflow-kotlin' - timeout-minutes: 45 + timeout-minutes: 90 steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 @@ -39,5 +38,7 @@ jobs: with: task: publish env: - ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_NEXUS_USERNAME }} - ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_NEXUS_PASSWORD }} + ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_CENTRAL_USERNAME }} + ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_CENTRAL_PASSWORD }} + ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.GPG_SECRET_KEY }} + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.GPG_SECRET_PASSPHRASE }} diff --git a/.gitignore b/.gitignore index b9beab33b..5a92170db 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ local.properties *.iml .idea/ captures/ + +# Kotlin Metadata +.kotlin/ diff --git a/RELEASING.md b/RELEASING.md index 880d9941f..713b593ff 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -93,7 +93,7 @@ 1. `git merge --no-ff origin/yourname/kotlin-v0.1.0` 1. `git push origin gh-pages` -1. _Don't do this until the tutorial gets updated._ Once Maven artifacts are synced, update the workflow version used by the tutorial in +1. Once Maven artifacts are synced, update the workflow version used by the tutorial in `samples/tutorial/build.gradle`. ### Validating Markdown diff --git a/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/PerformancePoetryActivity.kt b/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/PerformancePoetryActivity.kt index e68a33a9f..4d69ea406 100644 --- a/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/PerformancePoetryActivity.kt +++ b/benchmarks/performance-poetry/complex-poetry/src/main/java/com/squareup/benchmarks/performance/complex/poetry/PerformancePoetryActivity.kt @@ -1,7 +1,6 @@ package com.squareup.benchmarks.performance.complex.poetry import android.content.pm.ApplicationInfo -import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper @@ -23,12 +22,12 @@ import com.squareup.sample.poetry.model.Poem import com.squareup.workflow1.RuntimeConfig import com.squareup.workflow1.RuntimeConfigOptions.Companion.RENDER_PER_ACTION import com.squareup.workflow1.WorkflowInterceptor +import com.squareup.workflow1.android.renderWorkflowIn import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.ViewEnvironment.Companion.EMPTY import com.squareup.workflow1.ui.ViewRegistry -import com.squareup.workflow1.ui.WorkflowLayout -import com.squareup.workflow1.ui.renderWorkflowIn import com.squareup.workflow1.ui.withEnvironment +import com.squareup.workflow1.ui.workflowContentView import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach @@ -97,13 +96,9 @@ class PerformancePoetryActivity : AppCompatActivity() { model.renderings } - setContentView( - WorkflowLayout(this).apply { - take( - lifecycle, - instrumentedRenderings.map { it.withEnvironment(viewEnvironment) } - ) - } + workflowContentView.take( + lifecycle, + instrumentedRenderings.map { it.withEnvironment(viewEnvironment) } ) // We can report this here as the first rendering from the Workflow is rendered synchronously. @@ -125,7 +120,7 @@ class PerformancePoetryActivity : AppCompatActivity() { val traceMain = intent.getBooleanExtra(EXTRA_TRACE_ALL_MAIN_THREAD_MESSAGES, false) val traceSelectOnTimeout = intent.getBooleanExtra(EXTRA_TRACE_SELECT_TIMEOUTS, false) val areTracingViaMainLooperCurrently = traceMain || traceSelectOnTimeout - val ableToTrace = Build.VERSION.SDK_INT != 28 && (debuggable || profileable) + val ableToTrace = debuggable || profileable if (areTracingViaMainLooperCurrently && ableToTrace) { // Add main message tracing to see everything happening in Perfetto. @@ -265,7 +260,7 @@ class PoetryModel( runtimeConfig: RuntimeConfig ) : ViewModel() { val renderings: StateFlow by lazy { - renderWorkflowIn( + com.squareup.workflow1.android.renderWorkflowIn( workflow = workflow, scope = viewModelScope, savedStateHandle = savedState, diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index 2d5697a74..e9b187b70 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -62,6 +62,7 @@ dependencies { compileOnly(gradleApi()) + implementation(libs.burst.plugin) implementation(libs.android.gradle.plugin) implementation(libs.kgx) implementation(libs.dokka.gradle.plugin) diff --git a/build-logic/src/main/java/com/squareup/workflow1/buildsrc/PublishingConventionPlugin.kt b/build-logic/src/main/java/com/squareup/workflow1/buildsrc/PublishingConventionPlugin.kt index 56e2cd0a7..60d463706 100644 --- a/build-logic/src/main/java/com/squareup/workflow1/buildsrc/PublishingConventionPlugin.kt +++ b/build-logic/src/main/java/com/squareup/workflow1/buildsrc/PublishingConventionPlugin.kt @@ -48,7 +48,7 @@ class PublishingConventionPlugin : Plugin { } target.extensions.configure(MavenPublishBaseExtension::class.java) { basePluginExtension -> - basePluginExtension.publishToMavenCentral(SonatypeHost.S01, automaticRelease = true) + basePluginExtension.publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL, automaticRelease = true) // Will only apply to non snapshot builds. basePluginExtension.signAllPublications() // import all settings from root project and project-specific gradle.properties files diff --git a/dependencies/classpath.txt b/dependencies/classpath.txt index 3a9244895..17e46fec0 100644 --- a/dependencies/classpath.txt +++ b/dependencies/classpath.txt @@ -1,5 +1,9 @@ androidx.databinding:databinding-common:8.8.0 androidx.databinding:databinding-compiler-common:8.8.0 +app.cash.burst:burst-gradle-plugin:2.1.0 +app.cash.burst:burst-jvm:2.1.0 +app.cash.burst:burst-kotlin-plugin:2.1.0 +app.cash.burst:burst:2.1.0 com.android.databinding:baseLibrary:8.8.0 com.android.tools.analytics-library:crash:31.8.0 com.android.tools.analytics-library:protos:31.8.0 @@ -81,17 +85,17 @@ com.squareup.moshi:moshi:1.15.2 com.squareup.okhttp3:okhttp:4.12.0 com.squareup.okio:okio-jvm:3.7.0 com.squareup.okio:okio:3.7.0 -com.squareup.retrofit2:converter-moshi:2.11.0 -com.squareup.retrofit2:converter-scalars:2.11.0 -com.squareup.retrofit2:retrofit:2.11.0 +com.squareup.retrofit2:converter-moshi:3.0.0 +com.squareup.retrofit2:converter-scalars:3.0.0 +com.squareup.retrofit2:retrofit:3.0.0 com.squareup:javapoet:1.10.0 com.squareup:javawriter:2.5.0 com.sun.activation:javax.activation:1.2.0 com.sun.istack:istack-commons-runtime:3.0.8 com.sun.xml.fastinfoset:FastInfoset:1.2.16 -com.vanniktech:central-portal:0.32.0 -com.vanniktech:gradle-maven-publish-plugin:0.32.0 -com.vanniktech:nexus:0.32.0 +com.vanniktech:central-portal:0.33.0 +com.vanniktech:gradle-maven-publish-plugin:0.33.0 +com.vanniktech:nexus:0.33.0 commons-codec:commons-codec:1.11 commons-io:commons-io:2.13.0 commons-logging:commons-logging:1.2 diff --git a/gradle.properties b/gradle.properties index 4d98aee1a..be5c16c57 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,7 +8,7 @@ android.useAndroidX=true systemProp.org.gradle.internal.publish.checksums.insecure=true GROUP=com.squareup.workflow1 -VERSION_NAME=1.20.0-SNAPSHOT +VERSION_NAME=1.21.0-DEA2-SNAPSHOT POM_DESCRIPTION=Square Workflow diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 85cc6f751..c1b69d96d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -36,9 +36,13 @@ androidx-tracing = "1.2.0" androidx-transition = "1.5.1" androidx-viewbinding = "8.1.2" +burst = "2.1.0" + detekt = "1.19.0" dokka = "2.0.0" dependencyGuard = "0.5.0" +# Any version above 0.10.0-beta03 requires Compose 1.8.0 or higher, beta03 is 1.7.3 or higher. +filekit-dialogs-compose = "0.10.0-beta03" google-accompanist = "0.18.0" google-dagger = "2.40.5" @@ -78,8 +82,10 @@ squareup-curtains = "1.2.5" squareup-cycler = "0.1.9" squareup-leakcanary = "3.0-alpha-8" squareup-moshi = "1.15.0" +squareup-moshi-kotlin = "1.15.2" squareup-okhttp = "4.9.1" squareup-okio = "3.3.0" +squareup-papa = "0.30" squareup-radiography = "2.4.1" squareup-retrofit = "2.9.0" squareup-seismic = "1.0.3" @@ -88,7 +94,7 @@ squareup-workflow = "1.0.0" timber = "5.0.1" truth = "1.4.4" turbine = "1.0.0" -vanniktech-publish = "0.32.0" +vanniktech-publish = "0.33.0" [plugins] @@ -132,6 +138,7 @@ androidx-compose-runtime = { module = "androidx.compose.runtime:runtime" } androidx-compose-runtime-saveable = { module = "androidx.compose.runtime:runtime-saveable" } androidx-compose-ui = { module = "androidx.compose.ui:ui" } +androidx-compose-ui-android = { module = "androidx.compose.ui:ui-android" } androidx-compose-ui-geometry = { module = "androidx.compose.ui:ui-geometry" } androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } @@ -181,10 +188,13 @@ androidx-transition = { module = "androidx.transition:transition", version.ref = androidx-viewbinding = { module = "androidx.databinding:viewbinding", version.ref = "androidx-viewbinding" } +burst-plugin = { module = "app.cash.burst:burst-gradle-plugin", version.ref = "burst" } + dokka-gradle-plugin = { module = "org.jetbrains.dokka:dokka-gradle-plugin", version.ref = "dokka" } dropbox-dependencyGuard = { module = "com.dropbox.dependency-guard:dependency-guard", version.ref = "dependencyGuard" } +filekit-dialogs-compose = { module = "io.github.vinceglb:filekit-dialogs-compose", version.ref = "filekit-dialogs-compose" } google-android-material = { module = "com.google.android.material:material", version.ref = "material" } google-ksp = { module = "com.google.devtools.ksp:symbol-processing-gradle-plugin", version.ref = "google-ksp" } @@ -250,9 +260,12 @@ squareup-leakcanary-objectwatcher-android = { module = "com.squareup.leakcanary: squareup-moshi = { module = "com.squareup.moshi:moshi", version.ref = "squareup-moshi" } squareup-moshi-adapters = { module = "com.squareup.moshi:moshi-adapters", version.ref = "squareup-moshi" } squareup-moshi-codegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "squareup-moshi" } +squareup-moshi-kotlin = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "squareup-moshi-kotlin" } squareup-okio = { module = "com.squareup.okio:okio", version.ref = "squareup-okio" } +squareup-papa = { module = "com.squareup.papa:papa", version.ref = "squareup-papa"} + squareup-radiography = { module = "com.squareup.radiography:radiography", version.ref = "squareup-radiography" } squareup-seismic = { module = "com.squareup:seismic", version.ref = "squareup-seismic" } diff --git a/samples/compose-samples/src/androidTest/java/com/squareup/sample/compose/preview/PreviewTest.kt b/samples/compose-samples/src/androidTest/java/com/squareup/sample/compose/preview/PreviewTest.kt index 52fcabcbf..d669e8330 100644 --- a/samples/compose-samples/src/androidTest/java/com/squareup/sample/compose/preview/PreviewTest.kt +++ b/samples/compose-samples/src/androidTest/java/com/squareup/sample/compose/preview/PreviewTest.kt @@ -24,9 +24,9 @@ class PreviewTest { .around(IdlingDispatcherRule) @Test fun showsPreviewRendering() { - composeRule.onNodeWithText(ContactDetailsRendering::class.java.simpleName, substring = true) + composeRule.onNodeWithText(ContactDetailsScreen::class.java.simpleName, substring = true) .assertIsDisplayed() - .assertTextContains(previewContactRendering.details.phoneNumber, substring = true) - .assertTextContains(previewContactRendering.details.address, substring = true) + .assertTextContains(previewContactScreen.details.phoneNumber, substring = true) + .assertTextContains(previewContactScreen.details.address, substring = true) } } diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocompose/HelloComposeScreen.kt b/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocompose/HelloComposeScreen.kt index 99519c029..1923a1fcb 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocompose/HelloComposeScreen.kt +++ b/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocompose/HelloComposeScreen.kt @@ -16,16 +16,33 @@ data class HelloComposeScreen( val onClick: () -> Unit ) : ComposeScreen { @Composable override fun Content() { - Text( - message, - modifier = Modifier - .clickable(onClick = onClick) - .fillMaxSize() - .wrapContentSize(Alignment.Center) - ) + // It is best to keep this method as empty as possible to avoid + // capturing state from stale ComposeScreen instances, + // and to keep from interfering with Compose's stability checks. + // https://developer.android.com/develop/ui/compose/performance/stability + Hello(this) } } +/** + * @param modifier even though we use the default [Modifier] when calling + * from [HelloComposeScreen.Content], a habit of accepting this param from the + * Composable itself is handy for screenshot tests and previews. + */ +@Composable +private fun Hello( + screen: HelloComposeScreen, + modifier: Modifier = Modifier +) { + Text( + screen.message, + modifier = modifier + .clickable(onClick = screen.onClick) + .fillMaxSize() + .wrapContentSize(Alignment.Center) + ) +} + @Preview(heightDp = 150, showBackground = true) @Composable private fun HelloPreview() { diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposebinding/HelloBindingActivity.kt b/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposebinding/HelloBindingActivity.kt index 9178e33f2..0ee6ec464 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposebinding/HelloBindingActivity.kt +++ b/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposebinding/HelloBindingActivity.kt @@ -10,16 +10,16 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.squareup.workflow1.WorkflowExperimentalRuntime +import com.squareup.workflow1.android.renderWorkflowIn import com.squareup.workflow1.config.AndroidRuntimeConfigTools import com.squareup.workflow1.mapRendering import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.ViewRegistry -import com.squareup.workflow1.ui.WorkflowLayout import com.squareup.workflow1.ui.compose.withComposeInteropSupport import com.squareup.workflow1.ui.plus -import com.squareup.workflow1.ui.renderWorkflowIn import com.squareup.workflow1.ui.withEnvironment +import com.squareup.workflow1.ui.workflowContentView import kotlinx.coroutines.flow.StateFlow private val viewEnvironment = @@ -37,19 +37,15 @@ class HelloBindingActivity : AppCompatActivity() { super.onCreate(savedInstanceState) val model: HelloBindingModel by viewModels() - setContentView( - WorkflowLayout(this).apply { - take( - lifecycle = lifecycle, - renderings = model.renderings, - ) - } + workflowContentView.take( + lifecycle = lifecycle, + renderings = model.renderings, ) } class HelloBindingModel(savedState: SavedStateHandle) : ViewModel() { val renderings: StateFlow by lazy { - renderWorkflowIn( + com.squareup.workflow1.android.renderWorkflowIn( workflow = HelloWorkflow.mapRendering { it.withEnvironment(viewEnvironment) }, scope = viewModelScope, savedStateHandle = savedState, diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposeworkflow/HelloComposeWorkflowActivity.kt b/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposeworkflow/HelloComposeWorkflowActivity.kt index c56c7cdd2..f8067c470 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposeworkflow/HelloComposeWorkflowActivity.kt +++ b/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposeworkflow/HelloComposeWorkflowActivity.kt @@ -9,28 +9,26 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.squareup.workflow1.WorkflowExperimentalRuntime +import com.squareup.workflow1.android.renderWorkflowIn import com.squareup.workflow1.config.AndroidRuntimeConfigTools import com.squareup.workflow1.mapRendering import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.ViewEnvironment -import com.squareup.workflow1.ui.WorkflowLayout import com.squareup.workflow1.ui.compose.withComposeInteropSupport -import com.squareup.workflow1.ui.renderWorkflowIn import com.squareup.workflow1.ui.withEnvironment +import com.squareup.workflow1.ui.workflowContentView import kotlinx.coroutines.flow.StateFlow class HelloComposeWorkflowActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val model: HelloComposeModel by viewModels() - setContentView( - WorkflowLayout(this).apply { take(lifecycle, model.renderings) } - ) + workflowContentView.take(lifecycle, model.renderings) } class HelloComposeModel(savedState: SavedStateHandle) : ViewModel() { val renderings: StateFlow by lazy { - renderWorkflowIn( + com.squareup.workflow1.android.renderWorkflowIn( workflow = HelloWorkflow.mapRendering { it.withEnvironment(ViewEnvironment.EMPTY.withComposeInteropSupport()) }, diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/inlinerendering/InlineRenderingActivity.kt b/samples/compose-samples/src/main/java/com/squareup/sample/compose/inlinerendering/InlineRenderingActivity.kt index dd30c985f..764b1591d 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/inlinerendering/InlineRenderingActivity.kt +++ b/samples/compose-samples/src/main/java/com/squareup/sample/compose/inlinerendering/InlineRenderingActivity.kt @@ -9,32 +9,31 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.squareup.workflow1.WorkflowExperimentalRuntime +import com.squareup.workflow1.android.renderWorkflowIn import com.squareup.workflow1.config.AndroidRuntimeConfigTools import com.squareup.workflow1.mapRendering import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.ViewEnvironment -import com.squareup.workflow1.ui.WorkflowLayout import com.squareup.workflow1.ui.compose.withComposeInteropSupport -import com.squareup.workflow1.ui.renderWorkflowIn import com.squareup.workflow1.ui.withEnvironment +import com.squareup.workflow1.ui.workflowContentView import kotlinx.coroutines.flow.StateFlow /** - * A workflow that returns an anonymous `ComposeRendering`. + * A workflow that returns an anonymous + * [ComposeScreen][com.squareup.workflow1.ui.compose.ComposeScreen]. */ class InlineRenderingActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val model: HelloBindingModel by viewModels() - setContentView( - WorkflowLayout(this).apply { take(lifecycle, model.renderings) } - ) + workflowContentView.take(lifecycle, model.renderings) } class HelloBindingModel(savedState: SavedStateHandle) : ViewModel() { val renderings: StateFlow by lazy { - renderWorkflowIn( + com.squareup.workflow1.android.renderWorkflowIn( workflow = InlineRenderingWorkflow.mapRendering { it.withEnvironment(ViewEnvironment.EMPTY.withComposeInteropSupport()) }, diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/inlinerendering/InlineRenderingWorkflow.kt b/samples/compose-samples/src/main/java/com/squareup/sample/compose/inlinerendering/InlineRenderingWorkflow.kt index 8ce4577dc..87868f14b 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/inlinerendering/InlineRenderingWorkflow.kt +++ b/samples/compose-samples/src/main/java/com/squareup/sample/compose/inlinerendering/InlineRenderingWorkflow.kt @@ -39,20 +39,28 @@ object InlineRenderingWorkflow : StatefulWorkflow() ): ComposeScreen { val onClick = context.eventHandler("increment") { state += 1 } return ComposeScreen { - Box { - Button(onClick = onClick) { - Text("Counter: ") - AnimatedCounter(renderState) { counterValue -> - Text(counterValue.toString()) - } - } - } + Content(renderState, onClick) } } override fun snapshotState(state: Int): Snapshot = Snapshot.of(state) } +@Composable +private fun Content( + count: Int, + onClick: () -> Unit +) { + Box { + Button(onClick = onClick) { + Text("Counter: ") + AnimatedCounter(count) { counterValue -> + Text(counterValue.toString()) + } + } + } +} + @Composable fun InlineRenderingWorkflowRendering() { val rendering by InlineRenderingWorkflow.renderAsState( diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/NestedRenderingsActivity.kt b/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/NestedRenderingsActivity.kt index 4850f8a68..1e40c6d26 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/NestedRenderingsActivity.kt +++ b/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/NestedRenderingsActivity.kt @@ -11,16 +11,16 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.squareup.workflow1.WorkflowExperimentalRuntime +import com.squareup.workflow1.android.renderWorkflowIn import com.squareup.workflow1.config.AndroidRuntimeConfigTools import com.squareup.workflow1.mapRendering import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.ViewRegistry -import com.squareup.workflow1.ui.WorkflowLayout import com.squareup.workflow1.ui.compose.withComposeInteropSupport import com.squareup.workflow1.ui.plus -import com.squareup.workflow1.ui.renderWorkflowIn import com.squareup.workflow1.ui.withEnvironment +import com.squareup.workflow1.ui.workflowContentView import kotlinx.coroutines.flow.StateFlow private val viewRegistry = ViewRegistry(RecursiveComposableFactory) @@ -38,19 +38,12 @@ class NestedRenderingsActivity : AppCompatActivity() { super.onCreate(savedInstanceState) val model: NestedRenderingsModel by viewModels() - setContentView( - WorkflowLayout(this).apply { - take( - lifecycle = lifecycle, - renderings = model.renderings, - ) - } - ) + workflowContentView.take(lifecycle = lifecycle, renderings = model.renderings) } class NestedRenderingsModel(savedState: SavedStateHandle) : ViewModel() { val renderings: StateFlow by lazy { - renderWorkflowIn( + com.squareup.workflow1.android.renderWorkflowIn( workflow = RecursiveWorkflow.mapRendering { it.withEnvironment(viewEnvironment) }, scope = viewModelScope, savedStateHandle = savedState, diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/preview/PreviewActivity.kt b/samples/compose-samples/src/main/java/com/squareup/sample/compose/preview/PreviewActivity.kt index 0b7ca3e68..dbb2831d1 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/preview/PreviewActivity.kt +++ b/samples/compose-samples/src/main/java/com/squareup/sample/compose/preview/PreviewActivity.kt @@ -33,9 +33,9 @@ class PreviewActivity : AppCompatActivity() { } } -val previewContactRendering = ContactRendering( +val previewContactScreen = ContactScreen( name = "Dim Tonnelly", - details = ContactDetailsRendering( + details = ContactDetailsScreen( phoneNumber = "555-555-5555", address = "1234 Apgar Lane" ) @@ -46,27 +46,30 @@ val previewContactRendering = ContactRendering( fun PreviewApp() { MaterialTheme { Surface { - previewContactRendering.Preview() + previewContactScreen.Preview() } } } -data class ContactRendering( +data class ContactScreen( val name: String, - val details: ContactDetailsRendering + val details: ContactDetailsScreen ) : ComposeScreen { @Composable override fun Content() { - ContactDetails(this) + Contact(this) } } -data class ContactDetailsRendering( +// Note, not a ComposeScreen and has no view binding of any kind, +// which would normally be a runtime error. We're demonstrating that +// the preview is able to stub out the WorkflowRendering call below. +data class ContactDetailsScreen( val phoneNumber: String, val address: String ) : Screen @Composable -private fun ContactDetails(rendering: ContactRendering) { +private fun Contact(screen: ContactScreen) { Card( modifier = Modifier .padding(8.dp) @@ -76,9 +79,9 @@ private fun ContactDetails(rendering: ContactRendering) { modifier = Modifier.padding(16.dp), verticalArrangement = spacedBy(8.dp), ) { - Text(rendering.name, style = MaterialTheme.typography.body1) + Text(screen.name, style = MaterialTheme.typography.body1) WorkflowRendering( - rendering = rendering.details, + rendering = screen.details, modifier = Modifier .aspectRatio(1f) .border(0.dp, Color.LightGray) diff --git a/samples/containers/app-poetry/src/main/java/com/squareup/sample/poetryapp/PoetryActivity.kt b/samples/containers/app-poetry/src/main/java/com/squareup/sample/poetryapp/PoetryActivity.kt index ee85fa30b..bf4b9cd19 100644 --- a/samples/containers/app-poetry/src/main/java/com/squareup/sample/poetryapp/PoetryActivity.kt +++ b/samples/containers/app-poetry/src/main/java/com/squareup/sample/poetryapp/PoetryActivity.kt @@ -13,12 +13,12 @@ import com.squareup.sample.poetry.RealPoemWorkflow import com.squareup.sample.poetry.RealPoemsBrowserWorkflow import com.squareup.sample.poetry.model.Poem import com.squareup.workflow1.WorkflowExperimentalRuntime +import com.squareup.workflow1.android.renderWorkflowIn import com.squareup.workflow1.config.AndroidRuntimeConfigTools import com.squareup.workflow1.ui.Screen -import com.squareup.workflow1.ui.WorkflowLayout import com.squareup.workflow1.ui.navigation.reportNavigation -import com.squareup.workflow1.ui.renderWorkflowIn import com.squareup.workflow1.ui.withRegistry +import com.squareup.workflow1.ui.workflowContentView import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import timber.log.Timber @@ -30,11 +30,7 @@ class PoetryActivity : AppCompatActivity() { super.onCreate(savedInstanceState) val model: PoetryModel by viewModels() - setContentView( - WorkflowLayout(this).apply { - take(lifecycle, model.renderings.map { it.withRegistry(viewRegistry) }) - } - ) + workflowContentView.take(lifecycle, model.renderings.map { it.withRegistry(viewRegistry) }) } companion object { @@ -46,7 +42,7 @@ class PoetryActivity : AppCompatActivity() { class PoetryModel(savedState: SavedStateHandle) : ViewModel() { val renderings: Flow by lazy { - renderWorkflowIn( + com.squareup.workflow1.android.renderWorkflowIn( workflow = RealPoemsBrowserWorkflow(RealPoemWorkflow()), scope = viewModelScope, prop = 0 to 0 to Poem.allPoems, diff --git a/samples/containers/app-raven/src/main/java/com/squareup/sample/ravenapp/RavenActivity.kt b/samples/containers/app-raven/src/main/java/com/squareup/sample/ravenapp/RavenActivity.kt index 84b2823bb..c6d6f2d3a 100644 --- a/samples/containers/app-raven/src/main/java/com/squareup/sample/ravenapp/RavenActivity.kt +++ b/samples/containers/app-raven/src/main/java/com/squareup/sample/ravenapp/RavenActivity.kt @@ -13,12 +13,12 @@ import com.squareup.sample.container.SampleContainers import com.squareup.sample.poetry.RealPoemWorkflow import com.squareup.sample.poetry.model.Raven import com.squareup.workflow1.WorkflowExperimentalRuntime +import com.squareup.workflow1.android.renderWorkflowIn import com.squareup.workflow1.config.AndroidRuntimeConfigTools import com.squareup.workflow1.ui.Screen -import com.squareup.workflow1.ui.WorkflowLayout import com.squareup.workflow1.ui.navigation.reportNavigation -import com.squareup.workflow1.ui.renderWorkflowIn import com.squareup.workflow1.ui.withRegistry +import com.squareup.workflow1.ui.workflowContentView import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -32,11 +32,7 @@ class RavenActivity : AppCompatActivity() { super.onCreate(savedInstanceState) val model: RavenModel by viewModels() - setContentView( - WorkflowLayout(this).apply { - take(lifecycle, model.renderings.map { it.withRegistry(viewRegistry) }) - } - ) + workflowContentView.take(lifecycle, model.renderings.map { it.withRegistry(viewRegistry) }) lifecycleScope.launch { model.waitForExit() @@ -55,7 +51,7 @@ class RavenModel(savedState: SavedStateHandle) : ViewModel() { private val running = Job() val renderings: Flow by lazy { - renderWorkflowIn( + com.squareup.workflow1.android.renderWorkflowIn( workflow = RealPoemWorkflow(), scope = viewModelScope, savedStateHandle = savedState, diff --git a/samples/containers/hello-back-button/src/main/java/com/squareup/sample/hellobackbutton/HelloBackButtonActivity.kt b/samples/containers/hello-back-button/src/main/java/com/squareup/sample/hellobackbutton/HelloBackButtonActivity.kt index 688db25d7..7d0bcb822 100644 --- a/samples/containers/hello-back-button/src/main/java/com/squareup/sample/hellobackbutton/HelloBackButtonActivity.kt +++ b/samples/containers/hello-back-button/src/main/java/com/squareup/sample/hellobackbutton/HelloBackButtonActivity.kt @@ -11,12 +11,12 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.viewModelScope import com.squareup.sample.container.SampleContainers import com.squareup.workflow1.WorkflowExperimentalRuntime +import com.squareup.workflow1.android.renderWorkflowIn import com.squareup.workflow1.config.AndroidRuntimeConfigTools import com.squareup.workflow1.ui.Screen -import com.squareup.workflow1.ui.WorkflowLayout import com.squareup.workflow1.ui.navigation.reportNavigation -import com.squareup.workflow1.ui.renderWorkflowIn import com.squareup.workflow1.ui.withRegistry +import com.squareup.workflow1.ui.workflowContentView import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -30,11 +30,7 @@ class HelloBackButtonActivity : AppCompatActivity() { super.onCreate(savedInstanceState) val model: HelloBackButtonModel by viewModels() - setContentView( - WorkflowLayout(this).apply { - take(lifecycle, model.renderings.map { it.withRegistry(viewRegistry) }) - } - ) + workflowContentView.take(lifecycle, model.renderings.map { it.withRegistry(viewRegistry) }) lifecycleScope.launch { model.waitForExit() @@ -53,7 +49,7 @@ class HelloBackButtonModel(savedState: SavedStateHandle) : ViewModel() { private val running = Job() val renderings: Flow by lazy { - renderWorkflowIn( + com.squareup.workflow1.android.renderWorkflowIn( workflow = AreYouSureWorkflow, scope = viewModelScope, savedStateHandle = savedState, diff --git a/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/DungeonActivity.kt b/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/DungeonActivity.kt index 8f691299e..9757a9bc9 100644 --- a/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/DungeonActivity.kt +++ b/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/DungeonActivity.kt @@ -3,8 +3,8 @@ package com.squareup.sample.dungeon import android.os.Bundle import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity -import com.squareup.workflow1.ui.WorkflowLayout import com.squareup.workflow1.ui.withRegistry +import com.squareup.workflow1.ui.workflowContentView import kotlinx.coroutines.flow.map class DungeonActivity : AppCompatActivity() { @@ -16,9 +16,7 @@ class DungeonActivity : AppCompatActivity() { val component = Component(this) val model: TimeMachineModel by viewModels { component.timeMachineModelFactory } - val contentView = WorkflowLayout(this).apply { - take(lifecycle, model.renderings.map { it.withRegistry(component.viewRegistry) }) - } - setContentView(contentView) + workflowContentView + .take(lifecycle, model.renderings.map { it.withRegistry(component.viewRegistry) }) } } diff --git a/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/TimeMachineModel.kt b/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/TimeMachineModel.kt index 7ec6d5717..b98696cf3 100644 --- a/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/TimeMachineModel.kt +++ b/samples/dungeon/app/src/main/java/com/squareup/sample/dungeon/TimeMachineModel.kt @@ -6,10 +6,10 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.savedstate.SavedStateRegistryOwner import com.squareup.workflow1.WorkflowExperimentalRuntime +import com.squareup.workflow1.android.renderWorkflowIn import com.squareup.workflow1.config.AndroidRuntimeConfigTools import com.squareup.workflow1.diagnostic.tracing.TracingWorkflowInterceptor import com.squareup.workflow1.ui.Screen -import com.squareup.workflow1.ui.renderWorkflowIn import kotlinx.coroutines.flow.StateFlow import java.io.File import kotlin.time.ExperimentalTime @@ -23,7 +23,7 @@ class TimeMachineModel( val renderings: StateFlow by lazy { val traceFile = traceFilesDir.resolve("workflow-trace-dungeon.json") - renderWorkflowIn( + com.squareup.workflow1.android.renderWorkflowIn( workflow = workflow, prop = "simple_maze.txt", scope = viewModelScope, diff --git a/samples/hello-workflow-fragment/src/main/java/com/squareup/sample/helloworkflowfragment/HelloWorkflowFragment.kt b/samples/hello-workflow-fragment/src/main/java/com/squareup/sample/helloworkflowfragment/HelloWorkflowFragment.kt index 5bca0cdee..6ea21e19b 100644 --- a/samples/hello-workflow-fragment/src/main/java/com/squareup/sample/helloworkflowfragment/HelloWorkflowFragment.kt +++ b/samples/hello-workflow-fragment/src/main/java/com/squareup/sample/helloworkflowfragment/HelloWorkflowFragment.kt @@ -11,9 +11,9 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.squareup.workflow1.WorkflowExperimentalRuntime +import com.squareup.workflow1.android.renderWorkflowIn import com.squareup.workflow1.config.AndroidRuntimeConfigTools import com.squareup.workflow1.ui.WorkflowLayout -import com.squareup.workflow1.ui.renderWorkflowIn import kotlinx.coroutines.flow.StateFlow class HelloWorkflowFragment : Fragment() { @@ -35,7 +35,7 @@ class HelloWorkflowFragment : Fragment() { class HelloViewModel(savedState: SavedStateHandle) : ViewModel() { val renderings: StateFlow by lazy { - renderWorkflowIn( + com.squareup.workflow1.android.renderWorkflowIn( workflow = HelloWorkflow, scope = viewModelScope, savedStateHandle = savedState, diff --git a/samples/hello-workflow/src/main/java/com/squareup/sample/helloworkflow/HelloWorkflowActivity.kt b/samples/hello-workflow/src/main/java/com/squareup/sample/helloworkflow/HelloWorkflowActivity.kt index e641774af..e493fae3f 100644 --- a/samples/hello-workflow/src/main/java/com/squareup/sample/helloworkflow/HelloWorkflowActivity.kt +++ b/samples/hello-workflow/src/main/java/com/squareup/sample/helloworkflow/HelloWorkflowActivity.kt @@ -9,9 +9,9 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.squareup.workflow1.WorkflowExperimentalRuntime +import com.squareup.workflow1.android.renderWorkflowIn import com.squareup.workflow1.config.AndroidRuntimeConfigTools -import com.squareup.workflow1.ui.WorkflowLayout -import com.squareup.workflow1.ui.renderWorkflowIn +import com.squareup.workflow1.ui.workflowContentView import kotlinx.coroutines.flow.StateFlow class HelloWorkflowActivity : AppCompatActivity() { @@ -22,15 +22,13 @@ class HelloWorkflowActivity : AppCompatActivity() { // by the first call to viewModels(), and that original instance is returned by // succeeding calls. val model: HelloViewModel by viewModels() - setContentView( - WorkflowLayout(this).apply { take(lifecycle, model.renderings) } - ) + workflowContentView.take(lifecycle, model.renderings) } } class HelloViewModel(savedState: SavedStateHandle) : ViewModel() { val renderings: StateFlow by lazy { - renderWorkflowIn( + com.squareup.workflow1.android.renderWorkflowIn( workflow = HelloWorkflow, scope = viewModelScope, savedStateHandle = savedState, diff --git a/samples/nested-overlays/src/main/java/com/squareup/sample/nestedoverlays/NestedOverlaysActivity.kt b/samples/nested-overlays/src/main/java/com/squareup/sample/nestedoverlays/NestedOverlaysActivity.kt index 2d7f0a009..1ea8b497d 100644 --- a/samples/nested-overlays/src/main/java/com/squareup/sample/nestedoverlays/NestedOverlaysActivity.kt +++ b/samples/nested-overlays/src/main/java/com/squareup/sample/nestedoverlays/NestedOverlaysActivity.kt @@ -9,10 +9,10 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.squareup.workflow1.WorkflowExperimentalRuntime +import com.squareup.workflow1.android.renderWorkflowIn import com.squareup.workflow1.config.AndroidRuntimeConfigTools import com.squareup.workflow1.ui.Screen -import com.squareup.workflow1.ui.WorkflowLayout -import com.squareup.workflow1.ui.renderWorkflowIn +import com.squareup.workflow1.ui.workflowContentView import kotlinx.coroutines.flow.StateFlow class NestedOverlaysActivity : AppCompatActivity() { @@ -23,15 +23,13 @@ class NestedOverlaysActivity : AppCompatActivity() { // by the first call to viewModels(), and that original instance is returned by // succeeding calls. val model: NestedOverlaysViewModel by viewModels() - setContentView( - WorkflowLayout(this).apply { take(lifecycle, model.renderings) } - ) + workflowContentView.take(lifecycle, model.renderings) } } class NestedOverlaysViewModel(savedState: SavedStateHandle) : ViewModel() { val renderings: StateFlow by lazy { - renderWorkflowIn( + com.squareup.workflow1.android.renderWorkflowIn( workflow = NestedOverlaysWorkflow, scope = viewModelScope, savedStateHandle = savedState, diff --git a/samples/stub-visibility/src/main/java/com/squareup/sample/stubvisibility/StubVisibilityActivity.kt b/samples/stub-visibility/src/main/java/com/squareup/sample/stubvisibility/StubVisibilityActivity.kt index 5558a7286..1f6b8b08a 100644 --- a/samples/stub-visibility/src/main/java/com/squareup/sample/stubvisibility/StubVisibilityActivity.kt +++ b/samples/stub-visibility/src/main/java/com/squareup/sample/stubvisibility/StubVisibilityActivity.kt @@ -9,10 +9,10 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.squareup.workflow1.WorkflowExperimentalRuntime +import com.squareup.workflow1.android.renderWorkflowIn import com.squareup.workflow1.config.AndroidRuntimeConfigTools import com.squareup.workflow1.ui.Screen -import com.squareup.workflow1.ui.WorkflowLayout -import com.squareup.workflow1.ui.renderWorkflowIn +import com.squareup.workflow1.ui.workflowContentView import kotlinx.coroutines.flow.StateFlow class StubVisibilityActivity : AppCompatActivity() { @@ -20,15 +20,13 @@ class StubVisibilityActivity : AppCompatActivity() { super.onCreate(savedInstanceState) val model: StubVisibilityModel by viewModels() - setContentView( - WorkflowLayout(this).apply { take(lifecycle, model.renderings) } - ) + workflowContentView.take(lifecycle, model.renderings) } } class StubVisibilityModel(savedState: SavedStateHandle) : ViewModel() { val renderings: StateFlow by lazy { - renderWorkflowIn( + com.squareup.workflow1.android.renderWorkflowIn( workflow = StubVisibilityWorkflow, scope = viewModelScope, savedStateHandle = savedState, diff --git a/samples/tictactoe/app/src/main/java/com/squareup/sample/mainactivity/TicTacToeActivity.kt b/samples/tictactoe/app/src/main/java/com/squareup/sample/mainactivity/TicTacToeActivity.kt index 682240cea..d1e4a602b 100644 --- a/samples/tictactoe/app/src/main/java/com/squareup/sample/mainactivity/TicTacToeActivity.kt +++ b/samples/tictactoe/app/src/main/java/com/squareup/sample/mainactivity/TicTacToeActivity.kt @@ -8,9 +8,9 @@ import androidx.test.espresso.IdlingResource import com.squareup.sample.authworkflow.AuthViewFactories import com.squareup.sample.container.SampleContainers import com.squareup.sample.gameworkflow.TicTacToeViewFactories -import com.squareup.workflow1.ui.WorkflowLayout import com.squareup.workflow1.ui.plus import com.squareup.workflow1.ui.withRegistry +import com.squareup.workflow1.ui.workflowContentView import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import timber.log.Timber @@ -27,11 +27,7 @@ class TicTacToeActivity : AppCompatActivity() { idlingResource = component.idlingResource - setContentView( - WorkflowLayout(this).apply { - take(lifecycle, model.renderings.map { it.withRegistry(viewRegistry) }) - } - ) + workflowContentView.take(lifecycle, model.renderings.map { it.withRegistry(viewRegistry) }) lifecycleScope.launch { model.renderings.collect { Timber.d("rendering: %s", it) } diff --git a/samples/tictactoe/app/src/main/java/com/squareup/sample/mainactivity/TicTacToeModel.kt b/samples/tictactoe/app/src/main/java/com/squareup/sample/mainactivity/TicTacToeModel.kt index 3d4ae097c..082685f44 100644 --- a/samples/tictactoe/app/src/main/java/com/squareup/sample/mainactivity/TicTacToeModel.kt +++ b/samples/tictactoe/app/src/main/java/com/squareup/sample/mainactivity/TicTacToeModel.kt @@ -9,10 +9,10 @@ import androidx.lifecycle.viewModelScope import androidx.savedstate.SavedStateRegistryOwner import com.squareup.sample.mainworkflow.TicTacToeWorkflow import com.squareup.workflow1.WorkflowExperimentalRuntime +import com.squareup.workflow1.android.renderWorkflowIn import com.squareup.workflow1.config.AndroidRuntimeConfigTools import com.squareup.workflow1.diagnostic.tracing.TracingWorkflowInterceptor import com.squareup.workflow1.ui.Screen -import com.squareup.workflow1.ui.renderWorkflowIn import kotlinx.coroutines.Job import kotlinx.coroutines.flow.StateFlow import java.io.File @@ -27,7 +27,7 @@ class TicTacToeModel( val renderings: StateFlow by lazy { val traceFile = traceFilesDir.resolve("workflow-trace-tictactoe.json") - renderWorkflowIn( + com.squareup.workflow1.android.renderWorkflowIn( workflow = workflow, scope = viewModelScope, savedStateHandle = savedState, diff --git a/samples/todo-android/app/src/main/java/com/squareup/sample/todo/ToDoActivity.kt b/samples/todo-android/app/src/main/java/com/squareup/sample/todo/ToDoActivity.kt index d9a9cd419..2773ddf23 100644 --- a/samples/todo-android/app/src/main/java/com/squareup/sample/todo/ToDoActivity.kt +++ b/samples/todo-android/app/src/main/java/com/squareup/sample/todo/ToDoActivity.kt @@ -10,13 +10,13 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.squareup.sample.container.overviewdetail.OverviewDetailContainer import com.squareup.workflow1.WorkflowExperimentalRuntime +import com.squareup.workflow1.android.renderWorkflowIn import com.squareup.workflow1.config.AndroidRuntimeConfigTools import com.squareup.workflow1.diagnostic.tracing.TracingWorkflowInterceptor import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.ViewRegistry -import com.squareup.workflow1.ui.WorkflowLayout -import com.squareup.workflow1.ui.renderWorkflowIn import com.squareup.workflow1.ui.withRegistry +import com.squareup.workflow1.ui.workflowContentView import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map import java.io.File @@ -28,14 +28,11 @@ class ToDoActivity : AppCompatActivity() { val model: ToDoModel by viewModels() - setContentView( - WorkflowLayout(this).apply { - take( - lifecycle, - model.ensureWorkflow(traceFilesDir = filesDir).map { it.withRegistry(viewRegistry) } - ) - } - ) + workflowContentView + .take( + lifecycle, + model.ensureWorkflow(traceFilesDir = filesDir).map { it.withRegistry(viewRegistry) } + ) } private companion object { @@ -50,7 +47,7 @@ class ToDoModel(private val savedState: SavedStateHandle) : ViewModel() { if (renderings == null) { val traceFile = traceFilesDir.resolve("workflow-trace-todo.json") - renderings = renderWorkflowIn( + renderings = com.squareup.workflow1.android.renderWorkflowIn( workflow = TodoListsAppWorkflow, scope = viewModelScope, savedStateHandle = savedState, diff --git a/settings.gradle.kts b/settings.gradle.kts index f4982b30f..00f951b06 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -64,6 +64,7 @@ include( ":workflow-config:config-jvm", ":workflow-core", ":workflow-runtime", + ":workflow-runtime-android", ":workflow-rx2", ":workflow-testing", ":workflow-tracing", diff --git a/workflow-config/config-android/src/main/java/com/squareup/workflow1/config/AndroidRuntimeConfigTools.kt b/workflow-config/config-android/src/main/java/com/squareup/workflow1/config/AndroidRuntimeConfigTools.kt index 05c3f32ce..56798220e 100644 --- a/workflow-config/config-android/src/main/java/com/squareup/workflow1/config/AndroidRuntimeConfigTools.kt +++ b/workflow-config/config-android/src/main/java/com/squareup/workflow1/config/AndroidRuntimeConfigTools.kt @@ -3,6 +3,7 @@ package com.squareup.workflow1.config import com.squareup.workflow1.RuntimeConfig import com.squareup.workflow1.RuntimeConfigOptions import com.squareup.workflow1.RuntimeConfigOptions.CONFLATE_STALE_RENDERINGS +import com.squareup.workflow1.RuntimeConfigOptions.DRAIN_EXCLUSIVE_ACTIONS import com.squareup.workflow1.RuntimeConfigOptions.PARTIAL_TREE_RENDERING import com.squareup.workflow1.RuntimeConfigOptions.RENDER_ONLY_WHEN_STATE_CHANGES import com.squareup.workflow1.RuntimeConfigOptions.STABLE_EVENT_HANDLERS @@ -33,6 +34,10 @@ public class AndroidRuntimeConfigTools { * * - `stable` Enables stable event handlers (changes the default value of the `remember` * parameter of `RenderContext.eventHandler` functions from `false` to `true`) + * + * - `drainExclusive` Enables draining exclusive actions. If we have more actions to process + * that are queued on nodes not affected by the last action application, then we will + * continue to process those actions before another render pass. */ @WorkflowExperimentalRuntime public fun getAppWorkflowRuntimeConfig(): RuntimeConfig { @@ -48,6 +53,7 @@ public class AndroidRuntimeConfigTools { "stateChange" -> config.add(RENDER_ONLY_WHEN_STATE_CHANGES) "partial" -> config.addAll(setOf(RENDER_ONLY_WHEN_STATE_CHANGES, PARTIAL_TREE_RENDERING)) "stable" -> config.add(STABLE_EVENT_HANDLERS) + "drainExclusive" -> config.add(DRAIN_EXCLUSIVE_ACTIONS) else -> throw IllegalArgumentException("Unrecognized runtime config option \"$it\"") } } diff --git a/workflow-config/config-jvm/src/main/java/com/squareup/workflow1/config/JvmTestRuntimeConfigTools.kt b/workflow-config/config-jvm/src/main/java/com/squareup/workflow1/config/JvmTestRuntimeConfigTools.kt index da64d2fc3..2d525ff38 100644 --- a/workflow-config/config-jvm/src/main/java/com/squareup/workflow1/config/JvmTestRuntimeConfigTools.kt +++ b/workflow-config/config-jvm/src/main/java/com/squareup/workflow1/config/JvmTestRuntimeConfigTools.kt @@ -3,6 +3,7 @@ package com.squareup.workflow1.config import com.squareup.workflow1.RuntimeConfig import com.squareup.workflow1.RuntimeConfigOptions import com.squareup.workflow1.RuntimeConfigOptions.CONFLATE_STALE_RENDERINGS +import com.squareup.workflow1.RuntimeConfigOptions.DRAIN_EXCLUSIVE_ACTIONS import com.squareup.workflow1.RuntimeConfigOptions.PARTIAL_TREE_RENDERING import com.squareup.workflow1.RuntimeConfigOptions.RENDER_ONLY_WHEN_STATE_CHANGES import com.squareup.workflow1.RuntimeConfigOptions.STABLE_EVENT_HANDLERS @@ -35,6 +36,10 @@ public class JvmTestRuntimeConfigTools { * * - `stable` Enables stable event handlers (changes the default value of the `remember` * parameter of `RenderContext.eventHandler` functions from `false` to `true`) + * + * - `drainExclusive` Enables draining exclusive actions. If we have more actions to process + * that are queued on nodes not affected by the last action application, then we will + * continue to process those actions before another render pass. */ @OptIn(WorkflowExperimentalRuntime::class) public fun getTestRuntimeConfig(): RuntimeConfig { @@ -50,6 +55,7 @@ public class JvmTestRuntimeConfigTools { "stateChange" -> config.add(RENDER_ONLY_WHEN_STATE_CHANGES) "partial" -> config.addAll(setOf(RENDER_ONLY_WHEN_STATE_CHANGES, PARTIAL_TREE_RENDERING)) "stable" -> config.add(STABLE_EVENT_HANDLERS) + "drainExclusive" -> config.add(DRAIN_EXCLUSIVE_ACTIONS) else -> throw IllegalArgumentException("Unrecognized runtime config option \"$it\"") } } diff --git a/workflow-core/api/workflow-core.api b/workflow-core/api/workflow-core.api index 0230bc9f1..629b5d096 100644 --- a/workflow-core/api/workflow-core.api +++ b/workflow-core/api/workflow-core.api @@ -164,6 +164,7 @@ public final class com/squareup/workflow1/PropsUpdated : com/squareup/workflow1/ public final class com/squareup/workflow1/RuntimeConfigOptions : java/lang/Enum { public static final field CONFLATE_STALE_RENDERINGS Lcom/squareup/workflow1/RuntimeConfigOptions; public static final field Companion Lcom/squareup/workflow1/RuntimeConfigOptions$Companion; + public static final field DRAIN_EXCLUSIVE_ACTIONS Lcom/squareup/workflow1/RuntimeConfigOptions; public static final field PARTIAL_TREE_RENDERING Lcom/squareup/workflow1/RuntimeConfigOptions; public static final field RENDER_ONLY_WHEN_STATE_CHANGES Lcom/squareup/workflow1/RuntimeConfigOptions; public static final field STABLE_EVENT_HANDLERS Lcom/squareup/workflow1/RuntimeConfigOptions; @@ -178,6 +179,31 @@ public final class com/squareup/workflow1/RuntimeConfigOptions$Companion { public final fun getRENDER_PER_ACTION ()Ljava/util/Set; } +public final class com/squareup/workflow1/RuntimeConfigOptions$Companion$RuntimeOptions : java/lang/Enum { + public static final field CONFLATE Lcom/squareup/workflow1/RuntimeConfigOptions$Companion$RuntimeOptions; + public static final field DEA Lcom/squareup/workflow1/RuntimeConfigOptions$Companion$RuntimeOptions; + public static final field DEFAULT Lcom/squareup/workflow1/RuntimeConfigOptions$Companion$RuntimeOptions; + public static final field RENDER_ONLY Lcom/squareup/workflow1/RuntimeConfigOptions$Companion$RuntimeOptions; + public static final field RENDER_ONLY_CONFLATE Lcom/squareup/workflow1/RuntimeConfigOptions$Companion$RuntimeOptions; + public static final field RENDER_ONLY_CONFLATE_DEA Lcom/squareup/workflow1/RuntimeConfigOptions$Companion$RuntimeOptions; + public static final field RENDER_ONLY_CONFLATE_PARTIAL Lcom/squareup/workflow1/RuntimeConfigOptions$Companion$RuntimeOptions; + public static final field RENDER_ONLY_CONFLATE_PARTIAL_DEA Lcom/squareup/workflow1/RuntimeConfigOptions$Companion$RuntimeOptions; + public static final field RENDER_ONLY_CONFLATE_PARTIAL_STABLE Lcom/squareup/workflow1/RuntimeConfigOptions$Companion$RuntimeOptions; + public static final field RENDER_ONLY_CONFLATE_PARTIAL_STABLE_DEA Lcom/squareup/workflow1/RuntimeConfigOptions$Companion$RuntimeOptions; + public static final field RENDER_ONLY_CONFLATE_STABLE Lcom/squareup/workflow1/RuntimeConfigOptions$Companion$RuntimeOptions; + public static final field RENDER_ONLY_DEA Lcom/squareup/workflow1/RuntimeConfigOptions$Companion$RuntimeOptions; + public static final field RENDER_ONLY_DEA_STABLE Lcom/squareup/workflow1/RuntimeConfigOptions$Companion$RuntimeOptions; + public static final field RENDER_ONLY_PARTIAL Lcom/squareup/workflow1/RuntimeConfigOptions$Companion$RuntimeOptions; + public static final field RENDER_ONLY_PARTIAL_DEA Lcom/squareup/workflow1/RuntimeConfigOptions$Companion$RuntimeOptions; + public static final field RENDER_ONLY_PARTIAL_STABLE Lcom/squareup/workflow1/RuntimeConfigOptions$Companion$RuntimeOptions; + public static final field RENDER_ONLY_PARTIAL_STABLE_DEA Lcom/squareup/workflow1/RuntimeConfigOptions$Companion$RuntimeOptions; + public static final field STABLE Lcom/squareup/workflow1/RuntimeConfigOptions$Companion$RuntimeOptions; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public final fun getRuntimeConfig ()Ljava/util/Set; + public static fun valueOf (Ljava/lang/String;)Lcom/squareup/workflow1/RuntimeConfigOptions$Companion$RuntimeOptions; + public static fun values ()[Lcom/squareup/workflow1/RuntimeConfigOptions$Companion$RuntimeOptions; +} + public abstract class com/squareup/workflow1/SessionWorkflow : com/squareup/workflow1/StatefulWorkflow { public fun ()V public final fun initialState (Ljava/lang/Object;Lcom/squareup/workflow1/Snapshot;)Ljava/lang/Object; @@ -284,6 +310,17 @@ public final class com/squareup/workflow1/StatelessWorkflow$RenderContext : com/ public fun runningSideEffect (Ljava/lang/String;Lkotlin/jvm/functions/Function2;)V } +public final class com/squareup/workflow1/StatelessWorkflow$StatelessAsStatefulWorkflow : com/squareup/workflow1/StatefulWorkflow { + public fun (Lcom/squareup/workflow1/StatelessWorkflow;)V + public final fun clearCache ()V + public synthetic fun initialState (Ljava/lang/Object;Lcom/squareup/workflow1/Snapshot;)Ljava/lang/Object; + public fun initialState (Ljava/lang/Object;Lcom/squareup/workflow1/Snapshot;)V + public synthetic fun render (Ljava/lang/Object;Ljava/lang/Object;Lcom/squareup/workflow1/StatefulWorkflow$RenderContext;)Ljava/lang/Object; + public fun render (Ljava/lang/Object;Lkotlin/Unit;Lcom/squareup/workflow1/StatefulWorkflow$RenderContext;)Ljava/lang/Object; + public synthetic fun snapshotState (Ljava/lang/Object;)Lcom/squareup/workflow1/Snapshot; + public fun snapshotState (Lkotlin/Unit;)Lcom/squareup/workflow1/Snapshot; +} + public final class com/squareup/workflow1/TypedWorker : com/squareup/workflow1/Worker { public fun (Lkotlin/reflect/KType;Lkotlinx/coroutines/flow/Flow;)V public fun doesSameWorkAs (Lcom/squareup/workflow1/Worker;)Z diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/RuntimeConfig.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/RuntimeConfig.kt index 5c31f2ac1..d2e5ebf4d 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/RuntimeConfig.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/RuntimeConfig.kt @@ -66,6 +66,14 @@ public enum class RuntimeConfigOptions { */ @WorkflowExperimentalRuntime STABLE_EVENT_HANDLERS, + + /** + * If we have more actions to process that are queued on nodes not affected by the last + * action application, then we will continue to process those actions before another render + * pass. + */ + @WorkflowExperimentalRuntime + DRAIN_EXCLUSIVE_ACTIONS, ; public companion object { @@ -82,5 +90,77 @@ public enum class RuntimeConfigOptions { */ @WorkflowExperimentalRuntime public val ALL: RuntimeConfig = entries.toSet() + + /** + * Enum of all reasonable config options. Used especially for parameterized testing. + */ + @WorkflowExperimentalRuntime + enum class RuntimeOptions( + val runtimeConfig: RuntimeConfig + ) { + DEFAULT(RENDER_PER_ACTION), + RENDER_ONLY(setOf(RENDER_ONLY_WHEN_STATE_CHANGES)), + CONFLATE(setOf(CONFLATE_STALE_RENDERINGS)), + STABLE(setOf(STABLE_EVENT_HANDLERS)), + + DEA(setOf(DRAIN_EXCLUSIVE_ACTIONS)), + RENDER_ONLY_CONFLATE(setOf(RENDER_ONLY_WHEN_STATE_CHANGES, CONFLATE_STALE_RENDERINGS)), + RENDER_ONLY_PARTIAL(setOf(RENDER_ONLY_WHEN_STATE_CHANGES, PARTIAL_TREE_RENDERING)), + + RENDER_ONLY_DEA(setOf(RENDER_ONLY_WHEN_STATE_CHANGES, DRAIN_EXCLUSIVE_ACTIONS)), + RENDER_ONLY_CONFLATE_STABLE( + setOf(RENDER_ONLY_WHEN_STATE_CHANGES, CONFLATE_STALE_RENDERINGS, STABLE_EVENT_HANDLERS) + ), + RENDER_ONLY_CONFLATE_PARTIAL( + setOf(RENDER_ONLY_WHEN_STATE_CHANGES, CONFLATE_STALE_RENDERINGS, PARTIAL_TREE_RENDERING) + ), + + RENDER_ONLY_CONFLATE_DEA( + setOf(RENDER_ONLY_WHEN_STATE_CHANGES, CONFLATE_STALE_RENDERINGS, DRAIN_EXCLUSIVE_ACTIONS) + ), + RENDER_ONLY_PARTIAL_STABLE( + setOf(RENDER_ONLY_WHEN_STATE_CHANGES, PARTIAL_TREE_RENDERING, STABLE_EVENT_HANDLERS) + ), + + RENDER_ONLY_PARTIAL_DEA( + setOf(RENDER_ONLY_WHEN_STATE_CHANGES, PARTIAL_TREE_RENDERING, DRAIN_EXCLUSIVE_ACTIONS) + ), + RENDER_ONLY_DEA_STABLE( + setOf(RENDER_ONLY_WHEN_STATE_CHANGES, DRAIN_EXCLUSIVE_ACTIONS, STABLE_EVENT_HANDLERS) + ), + RENDER_ONLY_CONFLATE_PARTIAL_STABLE( + setOf( + RENDER_ONLY_WHEN_STATE_CHANGES, + CONFLATE_STALE_RENDERINGS, + PARTIAL_TREE_RENDERING, + STABLE_EVENT_HANDLERS, + ) + ), + RENDER_ONLY_CONFLATE_PARTIAL_DEA( + setOf( + RENDER_ONLY_WHEN_STATE_CHANGES, + CONFLATE_STALE_RENDERINGS, + PARTIAL_TREE_RENDERING, + DRAIN_EXCLUSIVE_ACTIONS, + ) + ), + RENDER_ONLY_PARTIAL_STABLE_DEA( + setOf( + RENDER_ONLY_WHEN_STATE_CHANGES, + PARTIAL_TREE_RENDERING, + STABLE_EVENT_HANDLERS, + DRAIN_EXCLUSIVE_ACTIONS, + ) + ), + RENDER_ONLY_CONFLATE_PARTIAL_STABLE_DEA( + setOf( + RENDER_ONLY_WHEN_STATE_CHANGES, + CONFLATE_STALE_RENDERINGS, + PARTIAL_TREE_RENDERING, + STABLE_EVENT_HANDLERS, + DRAIN_EXCLUSIVE_ACTIONS, + ) + ), + } } } diff --git a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/StatelessWorkflow.kt b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/StatelessWorkflow.kt index c13b47a9c..c9945b2af 100644 --- a/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/StatelessWorkflow.kt +++ b/workflow-core/src/commonMain/kotlin/com/squareup/workflow1/StatelessWorkflow.kt @@ -227,7 +227,7 @@ public abstract class StatelessWorkflow : * Class type returned by [asStatefulWorkflow]. * See [statefulWorkflow] for the instance. */ - private inner class StatelessAsStatefulWorkflow : + inner class StatelessAsStatefulWorkflow : StatefulWorkflow() { /** @@ -268,6 +268,23 @@ public abstract class StatelessWorkflow : } override fun snapshotState(state: Unit): Snapshot? = null + + /** + * When we are finished with at least one node that holds on to this workflow instance, + * then we clear the cache. The reason we do that every time is that it *might* be the last + * node that is caching this instance, and if so, we do not want to leak these cached + * render contexts. + * + * Yes, that means that it might have to be re-created again when this instance is used + * multiple times. The current design for how we get a [StatefulWorkflow] from the + * [StatelessWorkflow] is a failed compromise between performance (caching) and type-safe + * brevity (erasing the `StateT` type from the concerns of [StatelessWorkflow]). It needs + * to be fixed with a bigger re-write (https://github.com/square/workflow-kotlin/issues/1337). + */ + fun clearCache() { + cachedStatelessRenderContext = null + canonicalStatefulRenderContext = null + } } private val statefulWorkflow: StatefulWorkflow = diff --git a/workflow-runtime-android/README.md b/workflow-runtime-android/README.md new file mode 100644 index 000000000..f6ede30c2 --- /dev/null +++ b/workflow-runtime-android/README.md @@ -0,0 +1,11 @@ +# Module Workflow Runtime Android + +This module is an Android library that contains utilities to start a Workflow runtime that are +specific to Android components. This contains only the 'headless' components for Workflow on +Android; i.e. no UI concerns. + +See :workflow-ui:core-android for the complimentary helpers on Android that include UI concerns: +view model persistent, `WorkflowLayout`, etc. + +It also provides a place to include tests that verify behaviour of the runtime while using +Android specific dispatchers. diff --git a/workflow-runtime-android/api/workflow-runtime-android.api b/workflow-runtime-android/api/workflow-runtime-android.api new file mode 100644 index 000000000..4316d5d14 --- /dev/null +++ b/workflow-runtime-android/api/workflow-runtime-android.api @@ -0,0 +1,10 @@ +public final class com/squareup/workflow1/android/AndroidRenderWorkflowKt { + public static final fun removeWorkflowState (Landroidx/lifecycle/SavedStateHandle;)V + public static final fun renderWorkflowIn (Lcom/squareup/workflow1/Workflow;Lkotlinx/coroutines/CoroutineScope;Landroidx/lifecycle/SavedStateHandle;Ljava/util/List;Ljava/util/Set;Lcom/squareup/workflow1/WorkflowTracer;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/flow/StateFlow; + public static final fun renderWorkflowIn (Lcom/squareup/workflow1/Workflow;Lkotlinx/coroutines/CoroutineScope;Ljava/lang/Object;Landroidx/lifecycle/SavedStateHandle;Ljava/util/List;Ljava/util/Set;Lcom/squareup/workflow1/WorkflowTracer;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/flow/StateFlow; + public static final fun renderWorkflowIn (Lcom/squareup/workflow1/Workflow;Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/flow/StateFlow;Landroidx/lifecycle/SavedStateHandle;Ljava/util/List;Ljava/util/Set;Lcom/squareup/workflow1/WorkflowTracer;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/flow/StateFlow; + public static synthetic fun renderWorkflowIn$default (Lcom/squareup/workflow1/Workflow;Lkotlinx/coroutines/CoroutineScope;Landroidx/lifecycle/SavedStateHandle;Ljava/util/List;Ljava/util/Set;Lcom/squareup/workflow1/WorkflowTracer;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lkotlinx/coroutines/flow/StateFlow; + public static synthetic fun renderWorkflowIn$default (Lcom/squareup/workflow1/Workflow;Lkotlinx/coroutines/CoroutineScope;Ljava/lang/Object;Landroidx/lifecycle/SavedStateHandle;Ljava/util/List;Ljava/util/Set;Lcom/squareup/workflow1/WorkflowTracer;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lkotlinx/coroutines/flow/StateFlow; + public static synthetic fun renderWorkflowIn$default (Lcom/squareup/workflow1/Workflow;Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/flow/StateFlow;Landroidx/lifecycle/SavedStateHandle;Ljava/util/List;Ljava/util/Set;Lcom/squareup/workflow1/WorkflowTracer;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lkotlinx/coroutines/flow/StateFlow; +} + diff --git a/workflow-runtime-android/build.gradle.kts b/workflow-runtime-android/build.gradle.kts new file mode 100644 index 000000000..51e2837ff --- /dev/null +++ b/workflow-runtime-android/build.gradle.kts @@ -0,0 +1,34 @@ +plugins { + id("com.android.library") + id("kotlin-android") + id("android-defaults") + id("android-ui-tests") + id("app.cash.burst") +} + +android { + namespace = "com.squareup.workflow1.android" + testNamespace = "$namespace.test" +} + +dependencies { + val composeBom = platform(libs.androidx.compose.bom) + + api(project(":workflow-runtime")) + api(libs.androidx.compose.ui.android) + api(libs.androidx.lifecycle.viewmodel.savedstate) + + implementation(composeBom) + implementation(project(":workflow-core")) + + androidTestImplementation(libs.androidx.activity.ktx) + androidTestImplementation(libs.androidx.lifecycle.viewmodel.ktx) + androidTestImplementation(libs.androidx.test.core) + androidTestImplementation(libs.androidx.test.truth) + androidTestImplementation(libs.kotlin.test.core) + androidTestImplementation(libs.kotlin.test.jdk) + androidTestImplementation(libs.kotlinx.coroutines.android) + androidTestImplementation(libs.kotlinx.coroutines.core) + androidTestImplementation(libs.kotlinx.coroutines.test) + androidTestImplementation(libs.squareup.papa) +} diff --git a/workflow-runtime-android/gradle.properties b/workflow-runtime-android/gradle.properties new file mode 100644 index 000000000..5f09c5c15 --- /dev/null +++ b/workflow-runtime-android/gradle.properties @@ -0,0 +1,3 @@ +POM_ARTIFACT_ID=workflow-runtime-android +POM_NAME=Workflow Runtime Android +POM_PACKAGING=aar diff --git a/workflow-runtime-android/src/androidTest/AndroidManifest.xml b/workflow-runtime-android/src/androidTest/AndroidManifest.xml new file mode 100644 index 000000000..125820472 --- /dev/null +++ b/workflow-runtime-android/src/androidTest/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/workflow-runtime-android/src/androidTest/java/com/squareup/workflow1/android/AndroidDispatchersRenderWorkflowInTest.kt b/workflow-runtime-android/src/androidTest/java/com/squareup/workflow1/android/AndroidDispatchersRenderWorkflowInTest.kt new file mode 100644 index 000000000..dff93dc23 --- /dev/null +++ b/workflow-runtime-android/src/androidTest/java/com/squareup/workflow1/android/AndroidDispatchersRenderWorkflowInTest.kt @@ -0,0 +1,440 @@ +@file:OptIn(WorkflowExperimentalRuntime::class) +@file:Suppress("JUnitMalformedDeclaration") + +package com.squareup.workflow1.android + +import androidx.compose.ui.platform.AndroidUiDispatcher +import app.cash.burst.Burst +import com.squareup.workflow1.RenderingAndSnapshot +import com.squareup.workflow1.RuntimeConfigOptions.CONFLATE_STALE_RENDERINGS +import com.squareup.workflow1.RuntimeConfigOptions.Companion.RuntimeOptions +import com.squareup.workflow1.RuntimeConfigOptions.Companion.RuntimeOptions.DEFAULT +import com.squareup.workflow1.RuntimeConfigOptions.DRAIN_EXCLUSIVE_ACTIONS +import com.squareup.workflow1.Workflow +import com.squareup.workflow1.WorkflowAction +import com.squareup.workflow1.WorkflowExperimentalRuntime +import com.squareup.workflow1.WorkflowInterceptor +import com.squareup.workflow1.WorkflowInterceptor.RenderingProduced +import com.squareup.workflow1.WorkflowInterceptor.RuntimeUpdate +import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession +import com.squareup.workflow1.action +import com.squareup.workflow1.asWorker +import com.squareup.workflow1.renderChild +import com.squareup.workflow1.runningWorker +import com.squareup.workflow1.stateful +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.plus +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import papa.Choreographers +import java.util.concurrent.atomic.AtomicInteger +import kotlin.test.assertEquals + +@OptIn(WorkflowExperimentalRuntime::class) +@Burst +class AndroidDispatchersRenderWorkflowInTest( + private val runtime: RuntimeOptions = DEFAULT +) { + + private val trigger = MutableSharedFlow() + private val renderingsConsumed = mutableListOf() + private var renderingsProduced = 0 + private var renderPasses = 0 + private val countingInterceptor = object : WorkflowInterceptor { + override fun onRuntimeUpdate(update: RuntimeUpdate) { + if (update is RenderingProduced<*>) { + renderingsProduced++ + } + } + + override fun onRenderAndSnapshot( + renderProps: P, + proceed: (P) -> RenderingAndSnapshot, + session: WorkflowSession + ): RenderingAndSnapshot { + renderPasses++ + return proceed(renderProps) + } + } + private val orderIndex = AtomicInteger(0) + + private fun resetCounters() { + renderingsConsumed.clear() + renderingsProduced = 0 + renderPasses = 0 + orderIndex.set(0) + } + + @Before + fun setup() { + resetCounters() + } + + private fun expectInOrder( + expected: Int, + prefix: String = "" + ) { + val localActual = orderIndex.getAndIncrement() + assertEquals( + expected, + localActual, + "$prefix: This should have happened" + + " in a different order position:" + ) + } + + private fun runtimeOptimizationsTestHarness( + workflow: Workflow, + targetRendering: String, + expectedRenderPasses: Int, + expectedRenderingsProduced: Int, + expectedRenderingsConsumed: Int + ) = runTest { + val props = MutableStateFlow(Unit) + val renderings = renderWorkflowIn( + workflow = workflow, + scope = backgroundScope + + AndroidUiDispatcher.Main, + props = props, + runtimeConfig = runtime.runtimeConfig, + workflowTracer = null, + interceptors = listOf(countingInterceptor) + ) { } + + val targetRenderingReceived = Mutex(locked = true) + + val collectionJob = launch(AndroidUiDispatcher.Main) { + renderings.collect { + renderingsConsumed += it + if (it == targetRendering) { + // We expect to be able to consume our final rendering *before* the end of the frame. + expectInOrder(0) + targetRenderingReceived.unlock() + } + } + } + + launch(AndroidUiDispatcher.Main) { + Choreographers.postOnFrameRendered { + // We are expecting this to happen last, after we get the rendering! + expectInOrder(1) + } + trigger.emit("state change") + } + + targetRenderingReceived.lock() + collectionJob.cancel() + + assertEquals( + expected = expectedRenderPasses, + actual = renderPasses, + message = "Expected $expectedRenderPasses render passes." + ) + assertEquals( + expected = expectedRenderingsConsumed, + actual = renderingsConsumed.size, + message = "Expected $expectedRenderingsConsumed consumed renderings." + ) + assertEquals( + expected = expectedRenderingsProduced, + actual = renderingsProduced, + message = "Expected $expectedRenderingsProduced renderings to be produced" + + " (passed signal to interceptor)." + ) + assertEquals( + expected = targetRendering, + actual = renderingsConsumed.last() + ) + } + + @Test + fun optimizations_for_multiple_worker_actions_same_trigger() { + val childWorkflow = Workflow.stateful( + initialState = "unchanged state", + render = { renderState -> + runningWorker( + worker = trigger.asWorker(), + key = "Worker1" + ) { + action("childHandleWorker") { + val newState = "$it+u1" + state = newState + setOutput(newState) + } + } + renderState + } + ) + val workflow = Workflow.stateful( + initialState = "unchanged state", + render = { renderState -> + renderChild(childWorkflow) { childOutput -> + action("childHandleOutput") { + state = childOutput + } + } + runningWorker( + worker = trigger.asWorker(), + key = "Worker2" + ) { + action("handleWorker2") { + // Update the state in order to show conflation. + state = "$state+u2" + } + } + runningWorker( + worker = trigger.asWorker(), + key = "Worker3" + ) { + action("handleWorker3") { + // Update the state in order to show conflation. + state = "$state+u3" + } + } + runningWorker( + worker = trigger.asWorker(), + key = "Worker4" + ) { + action("handleWorker4") { + // Update the state in order to show conflation. + state = "$state+u4" + // Output only on the last one! + setOutput(state) + } + } + renderState + } + ) + runtimeOptimizationsTestHarness( + workflow = workflow, + targetRendering = "state change+u1+u2+u3+u4", + // There are 5 render passes the actions all update the same state. + expectedRenderPasses = 5, + // There are 2 attempts to produce a rendering for Conflate (initial and then the update.) + // And otherwise there are *5* attempts to produce a new rendering. + expectedRenderingsProduced = + if (runtime.runtimeConfig.contains(CONFLATE_STALE_RENDERINGS)) 2 else 5, + // Regardless only ever 2 renderings are consumed as the compose dispatcher drains all of + // the coroutines to update state before the collector can consume a rendering. + expectedRenderingsConsumed = 2 + ) + } + + @Test + fun optimizations_for_multiple_side_effect_actions() { + + val childWorkflow = Workflow.stateful( + initialState = "unchanged state", + render = { renderState -> + runningSideEffect("childSideEffect") { + trigger.collect { + actionSink.send( + action( + name = "handleChildSideEffectAction", + ) { + val newState = "$it+u1" + state = newState + setOutput(newState) + } + ) + } + } + renderState + } + ) + val workflow = Workflow.stateful( + initialState = "unchanged state", + render = { renderState -> + renderChild(childWorkflow) { childOutput -> + action("childHandler") { + state = childOutput + } + } + runningSideEffect("parentSideEffect") { + trigger.collect { + actionSink.send( + action( + name = "handleParentSideEffectAction", + ) { + state = "$state+u2" + } + ) + } + } + renderState + } + ) + runtimeOptimizationsTestHarness( + workflow = workflow, + targetRendering = "state change+u1+u2", + // There are 3 render passes as the actions all update the same state. + expectedRenderPasses = 3, + // There are 2 attempts to produce a rendering for Conflate (initial and then the update.) + // And otherwise there are *3* attempts to produce a new rendering. + expectedRenderingsProduced = + if (runtime.runtimeConfig.contains(CONFLATE_STALE_RENDERINGS)) 2 else 3, + // Regardless only ever 2 renderings are consumed as the compose dispatcher drains all of + // the coroutines to update state before the collector can consume a rendering. + expectedRenderingsConsumed = 2 + ) + } + + @Test + fun optimizations_for_exclusive_actions() { + + val childWorkflow = Workflow.stateful( + initialState = "unchanged state", + render = { renderState -> + runningWorker( + worker = trigger.asWorker(), + key = "Worker 1" + ) { + action("handleWorker1Output") { + state = "$it+u1" + setOutput("$it+u1") + } + } + renderState + } + ) + val workflow = Workflow.stateful( + initialState = "unchanged state", + render = { renderState -> + renderChild(childWorkflow, key = "key1") { _ -> + WorkflowAction.noAction() + } + renderChild(childWorkflow, key = "key2") { output -> + action(name = "child2Handler") { + state = output + } + } + renderState + } + ) + + runtimeOptimizationsTestHarness( + workflow = workflow, + targetRendering = "state change+u1", + // 2 for DEA (initial synchronous + 1 for the update); 3 otherwise given the 2 child actions. + expectedRenderPasses = if (runtime.runtimeConfig.contains(DRAIN_EXCLUSIVE_ACTIONS)) 2 else 3, + // There are 2 attempts to produce a rendering for Conflate & DEA (initial and then the + // update.) And otherwise there are *3* attempts to produce a new rendering. + expectedRenderingsProduced = + if (runtime.runtimeConfig.contains(CONFLATE_STALE_RENDERINGS) || + runtime.runtimeConfig.contains(DRAIN_EXCLUSIVE_ACTIONS) + ) { + 2 + } else { + 3 + }, + // Regardless only ever 2 renderings are consumed as the compose dispatcher drains all of + // the coroutines to update state before the collector can consume a rendering. + expectedRenderingsConsumed = 2 + ) + } + + private class SimpleScreen( + val name: String = "Empty", + val callback: () -> Unit, + ) + + @Test + fun all_runtimes_handle_side_effect_actions_before_the_next_frame() = + runTest { + val renderingUpdateComplete = Mutex(locked = true) + val trigger = MutableSharedFlow() + + val workflow = Workflow.stateful( + initialState = "unchanged state", + render = { renderState -> + runningSideEffect("only1") { + trigger.collect { + actionSink.send(action(name = "triggerCollect") { state = it }) + } + } + renderState + } + ) + + // We are rendering using Compose's AndroidUiDispatcher.Main. + val renderings = renderWorkflowIn( + workflow = workflow, + scope = backgroundScope + + AndroidUiDispatcher.Main, + props = MutableStateFlow(Unit).asStateFlow(), + runtimeConfig = runtime.runtimeConfig, + workflowTracer = null, + interceptors = emptyList() + ) {} + + val collectionJob = launch(AndroidUiDispatcher.Main) { + renderings.collect { + if (it == "changed state") { + // The rendering we were looking for! + expectInOrder(0) + renderingUpdateComplete.unlock() + } else { + Choreographers.postOnFrameRendered { + // We are expecting this to happen last, after we get the rendering! + expectInOrder(1) + } + trigger.emit("changed state") + } + } + } + + renderingUpdateComplete.lock() + collectionJob.cancel() + } + + @Test + fun all_runtimes_handle_rendering_events_before_next_frame() = runTest { + val renderingUpdateComplete = Mutex(locked = true) + val workflow = Workflow.stateful( + initialState = "neverends", + render = { renderState -> + SimpleScreen( + name = renderState, + callback = { + actionSink.send(action(name = "handleInput") { state = "$state+$state" }) + } + ) + } + ) + + val renderings = renderWorkflowIn( + workflow = workflow, + scope = backgroundScope + + AndroidUiDispatcher.Main, + props = MutableStateFlow(Unit).asStateFlow(), + runtimeConfig = runtime.runtimeConfig, + workflowTracer = null, + interceptors = emptyList() + ) {} + + val collectionJob = launch(AndroidUiDispatcher.Main) { + renderings.collect { + if (it.name == "neverends+neverends") { + // The rendering we were looking for after the event! + expectInOrder(0) + renderingUpdateComplete.unlock() + } else { + Choreographers.postOnFrameRendered { + // This should be happening last! + expectInOrder(1) + } + // First rendering, lets call it. + it.callback() + } + } + } + + renderingUpdateComplete.lock() + collectionJob.cancel() + } +} diff --git a/workflow-ui/core-android/src/androidTest/java/com/squareup/workflow1/ui/AndroidRenderWorkflowInTest.kt b/workflow-runtime-android/src/androidTest/java/com/squareup/workflow1/android/AndroidRenderWorkflowInTest.kt similarity index 90% rename from workflow-ui/core-android/src/androidTest/java/com/squareup/workflow1/ui/AndroidRenderWorkflowInTest.kt rename to workflow-runtime-android/src/androidTest/java/com/squareup/workflow1/android/AndroidRenderWorkflowInTest.kt index 0dadefba2..c68abb9f1 100644 --- a/workflow-ui/core-android/src/androidTest/java/com/squareup/workflow1/ui/AndroidRenderWorkflowInTest.kt +++ b/workflow-runtime-android/src/androidTest/java/com/squareup/workflow1/android/AndroidRenderWorkflowInTest.kt @@ -1,4 +1,4 @@ -package com.squareup.workflow1.ui +package com.squareup.workflow1.android import android.widget.FrameLayout import androidx.activity.ComponentActivity @@ -10,7 +10,12 @@ import androidx.lifecycle.viewModelScope import androidx.test.ext.junit.rules.ActivityScenarioRule import com.google.common.truth.Truth.assertThat import com.squareup.workflow1.StatelessWorkflow +import com.squareup.workflow1.ui.AndroidScreen +import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.ScreenViewFactory +import com.squareup.workflow1.ui.ScreenViewHolder import com.squareup.workflow1.ui.internal.test.IdlingDispatcherRule +import com.squareup.workflow1.ui.workflowContentView import kotlinx.coroutines.Job import kotlinx.coroutines.flow.StateFlow import leakcanary.DetectLeaksAfterTestSuccess @@ -38,9 +43,7 @@ internal class AndroidRenderWorkflowInTest { savedStateHandle = model.savedStateHandle ) - val layout = WorkflowLayout(activity) - activity.setContentView(layout) - + val layout = activity.workflowContentView assertThat(model.savedStateHandle.contains(KEY)).isFalse() job = layout.take(activity.lifecycle, renderings) diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/AndroidRenderWorkflow.kt b/workflow-runtime-android/src/main/java/com/squareup/workflow1/android/AndroidRenderWorkflow.kt similarity index 94% rename from workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/AndroidRenderWorkflow.kt rename to workflow-runtime-android/src/main/java/com/squareup/workflow1/android/AndroidRenderWorkflow.kt index 02e5bc00f..921bdfea0 100644 --- a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/AndroidRenderWorkflow.kt +++ b/workflow-runtime-android/src/main/java/com/squareup/workflow1/android/AndroidRenderWorkflow.kt @@ -1,6 +1,9 @@ -package com.squareup.workflow1.ui +@file:OptIn(ExperimentalStdlibApi::class) + +package com.squareup.workflow1.android import androidx.annotation.VisibleForTesting +import androidx.compose.ui.platform.AndroidUiDispatcher import androidx.lifecycle.SavedStateHandle import com.squareup.workflow1.RuntimeConfig import com.squareup.workflow1.RuntimeConfigOptions @@ -8,6 +11,7 @@ import com.squareup.workflow1.Workflow import com.squareup.workflow1.WorkflowInterceptor import com.squareup.workflow1.WorkflowTracer import com.squareup.workflow1.renderWorkflowIn +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted.Companion.Eagerly @@ -15,6 +19,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus /** * An Android `ViewModel`-friendly wrapper for [com.squareup.workflow1.renderWorkflowIn], @@ -171,7 +176,7 @@ public fun renderWorkflowIn( runtimeConfig: RuntimeConfig = RuntimeConfigOptions.DEFAULT_CONFIG, workflowTracer: WorkflowTracer? = null, onOutput: suspend (OutputT) -> Unit = {} -): StateFlow = renderWorkflowIn( +): StateFlow = com.squareup.workflow1.android.renderWorkflowIn( workflow, scope, MutableStateFlow(prop), @@ -272,9 +277,17 @@ public fun renderWorkflowIn( onOutput: suspend (OutputT) -> Unit = {} ): StateFlow { val restoredSnap = savedStateHandle?.get(KEY)?.snapshot + + // Add in Compose's AndroidUiDispatcher.Main by default if none is specified. + val updatedContext = if (scope.coroutineContext[CoroutineDispatcher.Key] == null) { + scope.coroutineContext + AndroidUiDispatcher.Main + } else { + scope.coroutineContext + } + val renderingsAndSnapshots = renderWorkflowIn( workflow, - scope, + scope + updatedContext, props, restoredSnap, interceptors, @@ -295,7 +308,7 @@ public fun renderWorkflowIn( * different Workflow runtimes in an app. Most apps will not use this function. */ public fun SavedStateHandle.removeWorkflowState() { - remove(KEY) + remove(com.squareup.workflow1.android.KEY) } @VisibleForTesting diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/PickledTreesnapshot.kt b/workflow-runtime-android/src/main/java/com/squareup/workflow1/android/PickledTreesnapshot.kt similarity index 95% rename from workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/PickledTreesnapshot.kt rename to workflow-runtime-android/src/main/java/com/squareup/workflow1/android/PickledTreesnapshot.kt index 3936e0416..b9f229725 100644 --- a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/PickledTreesnapshot.kt +++ b/workflow-runtime-android/src/main/java/com/squareup/workflow1/android/PickledTreesnapshot.kt @@ -1,4 +1,4 @@ -package com.squareup.workflow1.ui +package com.squareup.workflow1.android import android.os.Parcel import android.os.Parcelable diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/TreeSnapshotSaver.kt b/workflow-runtime-android/src/main/java/com/squareup/workflow1/android/TreeSnapshotSaver.kt similarity index 90% rename from workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/TreeSnapshotSaver.kt rename to workflow-runtime-android/src/main/java/com/squareup/workflow1/android/TreeSnapshotSaver.kt index 4c78a9707..2d3fc8876 100644 --- a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/TreeSnapshotSaver.kt +++ b/workflow-runtime-android/src/main/java/com/squareup/workflow1/android/TreeSnapshotSaver.kt @@ -1,11 +1,11 @@ -package com.squareup.workflow1.ui +package com.squareup.workflow1.android import android.os.Build.VERSION import android.os.Build.VERSION_CODES import android.os.Bundle import androidx.savedstate.SavedStateRegistry import com.squareup.workflow1.TreeSnapshot -import com.squareup.workflow1.ui.TreeSnapshotSaver.Companion.fromSavedStateRegistry +import com.squareup.workflow1.android.TreeSnapshotSaver.Companion.fromSavedStateRegistry /** * Persistence aid for [TreeSnapshot]. Use [fromSavedStateRegistry] to create one @@ -32,7 +32,7 @@ internal interface TreeSnapshotSaver { return if (VERSION.SDK_INT >= VERSION_CODES.TIRAMISU) { savedStateRegistry .consumeRestoredStateForKey(BUNDLE_KEY) - ?.getParcelable(BUNDLE_KEY, PickledTreesnapshot::class.java) + ?.getParcelable(BUNDLE_KEY, PickledTreesnapshot::class.java) ?.snapshot } else { @Suppress("DEPRECATION") diff --git a/workflow-runtime/api/workflow-runtime.api b/workflow-runtime/api/workflow-runtime.api index 150f63976..d5695ab67 100644 --- a/workflow-runtime/api/workflow-runtime.api +++ b/workflow-runtime/api/workflow-runtime.api @@ -4,7 +4,7 @@ public final class com/squareup/workflow1/NoopWorkflowInterceptor : com/squareup public fun onPropsChanged (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; public fun onRender (Ljava/lang/Object;Ljava/lang/Object;Lcom/squareup/workflow1/BaseRenderContext;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; public fun onRenderAndSnapshot (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/RenderingAndSnapshot; - public fun onRuntimeLoopTick (Lcom/squareup/workflow1/WorkflowInterceptor$RuntimeLoopOutcome;)V + public fun onRuntimeUpdate (Lcom/squareup/workflow1/WorkflowInterceptor$RuntimeUpdate;)V public fun onSessionStarted (Lkotlinx/coroutines/CoroutineScope;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)V public fun onSnapshotState (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/Snapshot; public fun onSnapshotStateWithChildren (Lkotlin/jvm/functions/Function0;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/TreeSnapshot; @@ -33,7 +33,7 @@ public class com/squareup/workflow1/SimpleLoggingWorkflowInterceptor : com/squar public fun onPropsChanged (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; public fun onRender (Ljava/lang/Object;Ljava/lang/Object;Lcom/squareup/workflow1/BaseRenderContext;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; public fun onRenderAndSnapshot (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/RenderingAndSnapshot; - public fun onRuntimeLoopTick (Lcom/squareup/workflow1/WorkflowInterceptor$RuntimeLoopOutcome;)V + public fun onRuntimeUpdate (Lcom/squareup/workflow1/WorkflowInterceptor$RuntimeUpdate;)V public fun onSessionStarted (Lkotlinx/coroutines/CoroutineScope;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)V public fun onSnapshotState (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/Snapshot; public fun onSnapshotStateWithChildren (Lkotlin/jvm/functions/Function0;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/TreeSnapshot; @@ -56,7 +56,7 @@ public abstract interface class com/squareup/workflow1/WorkflowInterceptor { public abstract fun onPropsChanged (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; public abstract fun onRender (Ljava/lang/Object;Ljava/lang/Object;Lcom/squareup/workflow1/BaseRenderContext;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; public abstract fun onRenderAndSnapshot (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/RenderingAndSnapshot; - public abstract fun onRuntimeLoopTick (Lcom/squareup/workflow1/WorkflowInterceptor$RuntimeLoopOutcome;)V + public abstract fun onRuntimeUpdate (Lcom/squareup/workflow1/WorkflowInterceptor$RuntimeUpdate;)V public abstract fun onSessionStarted (Lkotlinx/coroutines/CoroutineScope;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)V public abstract fun onSnapshotState (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/Snapshot; public abstract fun onSnapshotStateWithChildren (Lkotlin/jvm/functions/Function0;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/TreeSnapshot; @@ -67,7 +67,7 @@ public final class com/squareup/workflow1/WorkflowInterceptor$DefaultImpls { public static fun onPropsChanged (Lcom/squareup/workflow1/WorkflowInterceptor;Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; public static fun onRender (Lcom/squareup/workflow1/WorkflowInterceptor;Ljava/lang/Object;Ljava/lang/Object;Lcom/squareup/workflow1/BaseRenderContext;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; public static fun onRenderAndSnapshot (Lcom/squareup/workflow1/WorkflowInterceptor;Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/RenderingAndSnapshot; - public static fun onRuntimeLoopTick (Lcom/squareup/workflow1/WorkflowInterceptor;Lcom/squareup/workflow1/WorkflowInterceptor$RuntimeLoopOutcome;)V + public static fun onRuntimeUpdate (Lcom/squareup/workflow1/WorkflowInterceptor;Lcom/squareup/workflow1/WorkflowInterceptor$RuntimeUpdate;)V public static fun onSessionStarted (Lcom/squareup/workflow1/WorkflowInterceptor;Lkotlinx/coroutines/CoroutineScope;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)V public static fun onSnapshotState (Lcom/squareup/workflow1/WorkflowInterceptor;Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/Snapshot; public static fun onSnapshotStateWithChildren (Lcom/squareup/workflow1/WorkflowInterceptor;Lkotlin/jvm/functions/Function0;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/TreeSnapshot; @@ -87,19 +87,20 @@ public final class com/squareup/workflow1/WorkflowInterceptor$RenderContextInter public static fun onRunningSideEffect (Lcom/squareup/workflow1/WorkflowInterceptor$RenderContextInterceptor;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V } -public final class com/squareup/workflow1/WorkflowInterceptor$RenderPassSkipped : com/squareup/workflow1/WorkflowInterceptor$RuntimeLoopOutcome { - public fun ()V - public fun (Z)V - public synthetic fun (ZILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun getEndOfTick ()Z +public final class com/squareup/workflow1/WorkflowInterceptor$RenderPassSkipped : com/squareup/workflow1/WorkflowInterceptor$RuntimeUpdate { + public static final field INSTANCE Lcom/squareup/workflow1/WorkflowInterceptor$RenderPassSkipped; +} + +public final class com/squareup/workflow1/WorkflowInterceptor$RenderingConflated : com/squareup/workflow1/WorkflowInterceptor$RuntimeUpdate { + public static final field INSTANCE Lcom/squareup/workflow1/WorkflowInterceptor$RenderingConflated; } -public final class com/squareup/workflow1/WorkflowInterceptor$RenderPassesComplete : com/squareup/workflow1/WorkflowInterceptor$RuntimeLoopOutcome { +public final class com/squareup/workflow1/WorkflowInterceptor$RenderingProduced : com/squareup/workflow1/WorkflowInterceptor$RuntimeUpdate { public fun (Lcom/squareup/workflow1/RenderingAndSnapshot;)V public final fun getRenderingAndSnapshot ()Lcom/squareup/workflow1/RenderingAndSnapshot; } -public abstract interface class com/squareup/workflow1/WorkflowInterceptor$RuntimeLoopOutcome { +public abstract interface class com/squareup/workflow1/WorkflowInterceptor$RuntimeUpdate { } public abstract interface class com/squareup/workflow1/WorkflowInterceptor$WorkflowSession { @@ -116,3 +117,12 @@ public final class com/squareup/workflow1/WorkflowInterceptor$WorkflowSession$De public static fun isRootWorkflow (Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Z } +public final class com/squareup/workflow1/internal/ThrowablesKt { + public static final fun requireNotNullWithKey (Ljava/lang/Object;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;)Ljava/lang/Object; + public static synthetic fun requireNotNullWithKey$default (Ljava/lang/Object;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Ljava/lang/Object; +} + +public final class com/squareup/workflow1/internal/Throwables_jvmKt { + public static final fun withKey (Ljava/lang/Throwable;Ljava/lang/Object;)Ljava/lang/Throwable; +} + diff --git a/workflow-runtime/build.gradle.kts b/workflow-runtime/build.gradle.kts index d789bda88..3530f2a9c 100644 --- a/workflow-runtime/build.gradle.kts +++ b/workflow-runtime/build.gradle.kts @@ -3,6 +3,7 @@ import com.squareup.workflow1.buildsrc.iosWithSimulatorArm64 plugins { id("kotlin-multiplatform") id("published") + id("app.cash.burst") } kotlin { diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/RenderWorkflow.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/RenderWorkflow.kt index 78a25f097..367cdb6e8 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/RenderWorkflow.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/RenderWorkflow.kt @@ -1,9 +1,11 @@ package com.squareup.workflow1 import com.squareup.workflow1.RuntimeConfigOptions.CONFLATE_STALE_RENDERINGS +import com.squareup.workflow1.RuntimeConfigOptions.DRAIN_EXCLUSIVE_ACTIONS import com.squareup.workflow1.RuntimeConfigOptions.RENDER_ONLY_WHEN_STATE_CHANGES import com.squareup.workflow1.WorkflowInterceptor.RenderPassSkipped -import com.squareup.workflow1.WorkflowInterceptor.RenderPassesComplete +import com.squareup.workflow1.WorkflowInterceptor.RenderingConflated +import com.squareup.workflow1.WorkflowInterceptor.RenderingProduced import com.squareup.workflow1.internal.WorkflowRunner import com.squareup.workflow1.internal.chained import kotlinx.coroutines.CancellationException @@ -132,7 +134,7 @@ public fun renderWorkflowIn( val renderingsAndSnapshots = MutableStateFlow( try { runner.nextRendering().also { - chainedInterceptor.onRuntimeLoopTick(RenderPassesComplete(it)) + chainedInterceptor.onRuntimeUpdate(RenderingProduced(it)) } } catch (e: Throwable) { // If any part of the workflow runtime fails, the scope should be cancelled. We're not in a @@ -178,60 +180,78 @@ public fun renderWorkflowIn( scope.launch { outer@ while (isActive) { - // It might look weird to start by processing an action before getting the rendering below, + // It might look weird to start by waiting for an action before getting the rendering below, // but remember the first render pass already occurred above, before this coroutine was even // launched. - var actionResult: ActionProcessingResult = runner.processAction() + var actionResult: ActionProcessingResult = runner.waitAndProcessAction() if (shouldShortCircuitForUnchangedState(actionResult)) { - chainedInterceptor.onRuntimeLoopTick(RenderPassSkipped()) + chainedInterceptor.onRuntimeUpdate(RenderPassSkipped) sendOutput(actionResult, onOutput) continue@outer } - // After resuming from runner.processAction() our coroutine could now be cancelled, check so - // we don't surprise anyone with an unexpected rendering pass. Show's over, go home. + // After resuming from runner.waitAndProcessAction() our coroutine could now be cancelled, + // check so we don't surprise anyone with an unexpected rendering pass. Show's over, go home. if (!isActive) return@launch + var drainingActionResult = actionResult + var actionDrainingHasChangedState = false + if (runtimeConfig.contains(DRAIN_EXCLUSIVE_ACTIONS)) { + drain@ while (isActive && drainingActionResult is ActionApplied<*> && + drainingActionResult.output == null + ) { + actionDrainingHasChangedState = + actionDrainingHasChangedState || drainingActionResult.stateChanged + + drainingActionResult = runner.applyNextAvailableAction(skipDirtyNodes = true) + + // If no actions processed, then we can't apply any more actions. + if (drainingActionResult == ActionsExhausted) break@drain + + // Update actionResult to continue on below. + actionResult = drainingActionResult + chainedInterceptor.onRuntimeUpdate(RenderPassSkipped) + } + } + // Next Render Pass. var nextRenderAndSnapshot: RenderingAndSnapshot = runner.nextRendering() if (runtimeConfig.contains(CONFLATE_STALE_RENDERINGS)) { - var conflationHasChangedState = false - conflate@ while (isActive && actionResult is ActionApplied<*> && actionResult.output == null) { - conflationHasChangedState = conflationHasChangedState || actionResult.stateChanged + conflate@ while (isActive && actionResult is ActionApplied<*> && + actionResult.output == null + ) { + actionDrainingHasChangedState = + actionDrainingHasChangedState || actionResult.stateChanged // We may have more actions we can process, this rendering could be stale. - actionResult = runner.processAction(waitForAnAction = false) + // This will check for any actions that are immediately available and apply them. + actionResult = runner.applyNextAvailableAction() // If no actions processed, then no new rendering needed. Pass on to UI. if (actionResult == ActionsExhausted) break@conflate // Skip rendering if we had unchanged state, keep draining actions. - if (shouldShortCircuitForUnchangedState( - actionResult = actionResult, - ) - ) { - if (conflationHasChangedState) { - chainedInterceptor.onRuntimeLoopTick(RenderPassSkipped(endOfTick = false)) - // An earlier render changed state, so we need to pass that to the UI then we - // can skip this render. + if (shouldShortCircuitForUnchangedState(actionResult = actionResult)) { + chainedInterceptor.onRuntimeUpdate(RenderPassSkipped) + if (actionDrainingHasChangedState) { + // An earlier action changed state, so we need to pass the updated rendering to UI + // in case it is the last update! break@conflate } - chainedInterceptor.onRuntimeLoopTick(RenderPassSkipped()) sendOutput(actionResult, onOutput) continue@outer } - // Make sure the runtime has not been cancelled from runner.processAction() - if (!isActive) return@launch - + // Render pass for the updated state from the action applied. nextRenderAndSnapshot = runner.nextRendering() + chainedInterceptor.onRuntimeUpdate(RenderingConflated) } } // Pass on the rendering to the UI. renderingsAndSnapshots.value = nextRenderAndSnapshot.also { - chainedInterceptor.onRuntimeLoopTick(RenderPassesComplete(it)) + chainedInterceptor.onRuntimeUpdate(RenderingProduced(it)) } // Emit the Output diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowInterceptor.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowInterceptor.kt index 1b27b2807..cd56aac5c 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowInterceptor.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/WorkflowInterceptor.kt @@ -34,9 +34,7 @@ import kotlin.reflect.KType * * 1. [onSessionStarted] - called when a new [WorkflowSession] is created the first time a * workflow is rendered with the [CoroutineScope] for that session. - * 1. [onRuntimeLoopTick] - Called to report the [RuntimeLoopOutcome] of each tick of the runtime - * loop. In the simplest case this is the application of one action and one render pass, but - * optimizations can change that. See [onRenderingUpdated] for more. + * 1. [onRuntimeUpdate] - Called to report [RuntimeUpdate]s from the event loop. * * ## On Profiling * @@ -140,43 +138,56 @@ public interface WorkflowInterceptor { ): Snapshot? = proceed(state) /** - * Called to report the [outcome] of each tick of the runtime loop. In the simplest case - * this is the application of one action and one render pass, but optimizations can - * change that: + * Called to report the [update]s from the runtime as it executes its loop. * - * - With the `RENDER_ONLY_WHEN_STATE_CHANGES` optimization, there may not be a render pass - * at all, in which case [RenderPassSkipped] is the outcome. - * - With the `CONFLATE_STALE_RENDERINGS` optimization, there could be multiple render passes. - * - If there is at least one render pass, then [RenderPassesComplete] is passed as the outcome, - * which includes the actual [RenderingAndSnapshot] returned from the runtime. + * There will be at least one [update] for every application of an action, as this is how the + * runtime is advancing the state machine. * - * @param outcome The [RuntimeLoopOutcome] of the tick of the runtime loop. + * The possible [update]s are: + * + * 1. [RenderingProduced]: The runtime produced a new rendering for the view code. + * 1. [RenderPassSkipped]: Optimizations were able to apply multiple actions and skip a render + * pass + * 1. [RenderingConflated]: The runtime detected that the rendering was stale, so it conflates + * this rendering with the next before producing it for the view code. + * + * In the simplest case, the [update] for an action may be just *one* [RenderingProduced] - in + * which case we know there was one action applied, and one render pass. + * + * If there are optimizations applied, then they will try to greedily apply actions. In this case, + * for each action that is applied without a corresponding render pass, the [RenderPassSkipped] + * [update] will be reported here, and for each rendering that was conflated before producing it + * for the view code, the [RenderingConflated] [update] is reported. + * + * @param update A [RuntimeUpdate] from the event loop. */ - public fun onRuntimeLoopTick( - outcome: RuntimeLoopOutcome + public fun onRuntimeUpdate( + update: RuntimeUpdate ): Unit = Unit - public sealed interface RuntimeLoopOutcome + public sealed interface RuntimeUpdate /** - * @param endOfTick This is true if this skip was the end of the loop iteration (i.e. no update - * was passed to the UI. It is false otherwise. An example of when it can be false is if - * we have an action that causes a state change, but then we can process more while the - * `CONFLATE_STALE_RENDERINGS` optimization is enabled. We may skip rendering based on - * the subsequent action not changing state, but we will need to finish the loop and update - * the UI from the previous action that changed state. + * A render pass has been skipped by an optimization, multiple actions are applied before + * the runtime produces a rendering. */ - public class RenderPassSkipped( - public val endOfTick: Boolean = true - ) : RuntimeLoopOutcome + public object RenderPassSkipped : RuntimeUpdate /** + * The runtime skipped producing a rendering, conflating it to the next rendering after the next + * render pass. + */ + public object RenderingConflated : RuntimeUpdate + + /** + * This runtime has produced a new rendering after at least one render pass. + * * @param renderingAndSnapshot This is the rendering and snapshot that was passed out of the * Workflow runtime. */ - public class RenderPassesComplete( + public class RenderingProduced( public val renderingAndSnapshot: RenderingAndSnapshot - ) : RuntimeLoopOutcome + ) : RuntimeUpdate /** * Information about the session of a workflow in the runtime that a [WorkflowInterceptor] method diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/ChainedWorkflowInterceptor.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/ChainedWorkflowInterceptor.kt index 27719f988..f5c405227 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/ChainedWorkflowInterceptor.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/ChainedWorkflowInterceptor.kt @@ -9,7 +9,7 @@ import com.squareup.workflow1.Workflow import com.squareup.workflow1.WorkflowAction import com.squareup.workflow1.WorkflowInterceptor import com.squareup.workflow1.WorkflowInterceptor.RenderContextInterceptor -import com.squareup.workflow1.WorkflowInterceptor.RuntimeLoopOutcome +import com.squareup.workflow1.WorkflowInterceptor.RuntimeUpdate import com.squareup.workflow1.WorkflowInterceptor.WorkflowSession import kotlinx.coroutines.CoroutineScope import kotlin.reflect.KType @@ -124,9 +124,9 @@ internal class ChainedWorkflowInterceptor( return chainedProceed(state) } - override fun onRuntimeLoopTick(outcome: RuntimeLoopOutcome) { + override fun onRuntimeUpdate(update: RuntimeUpdate) { interceptors.forEach { interceptor -> - interceptor.onRuntimeLoopTick(outcome) + interceptor.onRuntimeUpdate(update) } } diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/SubtreeManager.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/SubtreeManager.kt index 09fb7608a..0c4cc8c6a 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/SubtreeManager.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/SubtreeManager.kt @@ -2,6 +2,7 @@ package com.squareup.workflow1.internal import com.squareup.workflow1.ActionApplied import com.squareup.workflow1.ActionProcessingResult +import com.squareup.workflow1.ActionsExhausted import com.squareup.workflow1.NoopWorkflowInterceptor import com.squareup.workflow1.RuntimeConfig import com.squareup.workflow1.TreeSnapshot @@ -148,17 +149,31 @@ internal class SubtreeManager( /** * Uses [selector] to invoke [WorkflowNode.onNextAction] for every running child workflow this instance * is managing. + */ + fun onNextChildAction(selector: SelectBuilder) { + children.forEachActive { child -> + child.workflowNode.onNextAction(selector) + } + } + + /** + * Will try to apply any actions immediately available in our children's actions queues. * - * @return [Boolean] whether or not the children action queues are empty. + * @param skipDirtyNodes Whether or not this should skip over any workflow nodes that are already + * 'dirty' - that is, they had their own state changed as the result of a previous action before + * the next render pass. + * + * @return [ActionProcessingResult] of the action processed, or [ActionsExhausted] if there were + * none immediately available. */ - fun onNextChildAction(selector: SelectBuilder): Boolean { - var empty = true + fun applyNextAvailableChildAction(skipDirtyNodes: Boolean = false): ActionProcessingResult { children.forEachActive { child -> - // Do this separately so the compiler doesn't avoid it if empty is already false. - val childEmpty = child.workflowNode.onNextAction(selector) - empty = childEmpty && empty + val result = child.workflowNode.applyNextAvailableAction(skipDirtyNodes) + if (result != ActionsExhausted) { + return result + } } - return empty + return ActionsExhausted } fun createChildSnapshots(): Map { diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/Throwables.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/Throwables.kt index c0d4353d5..47bdfac41 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/Throwables.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/Throwables.kt @@ -3,9 +3,36 @@ package com.squareup.workflow1.internal import kotlin.contracts.ExperimentalContracts import kotlin.contracts.contract +/** + * Like Kotlin's [requireNotNull], but uses [stackTraceKey] to create a fake top element + * on the stack trace, ensuring that a crash reporter's default grouping will create unique + * groups for unique keys. + * + * @see [withKey] + * + * @throws IllegalArgumentException if the [value] is false. + */ +@OptIn(ExperimentalContracts::class) +inline fun requireNotNullWithKey( + value: T?, + stackTraceKey: Any, + lazyMessage: () -> Any = { "Required value was null." } +): T { + contract { + returns() implies (value != null) + } + if (value == null) { + val message = lazyMessage() + val exception: Throwable = IllegalArgumentException(message.toString()) + throw exception.withKey(stackTraceKey) + } else { + return value + } +} + /** * Like Kotlin's [require], but uses [stackTraceKey] to create a fake top element - * on the stack trace, ensuring that crash reporter's default grouping will create unique + * on the stack trace, ensuring that a crash reporter's default grouping will create unique * groups for unique keys. * * So far [stackTraceKey] is only effective on JVM, it has no effect in other languages. @@ -36,7 +63,7 @@ internal inline fun requireWithKey( /** * Like Kotlin's [check], but uses [stackTraceKey] to create a fake top element - * on the stack trace, ensuring that crash reporter's default grouping will create unique + * on the stack trace, ensuring that a crash reporter's default grouping will create unique * groups for unique keys. * * So far [stackTraceKey] is only effective on JVM, it has no effect in other languages. @@ -67,7 +94,7 @@ internal inline fun checkWithKey( /** * Uses [stackTraceKey] to create a fake top element on the stack trace, ensuring - * that crash reporter's default grouping will create unique groups for unique keys. + * that a crash reporter's default grouping will create unique groups for unique keys. * * So far only effective on JVM, this is a pass through in other languages. * @@ -75,4 +102,4 @@ internal inline fun checkWithKey( * for crash reporters. It is important that keys are stable across processes, * avoid system hashes. */ -internal expect fun T.withKey(stackTraceKey: Any): T +expect fun T.withKey(stackTraceKey: Any): T diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowNode.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowNode.kt index 17dc3ff7d..043c1507d 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowNode.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowNode.kt @@ -2,6 +2,7 @@ package com.squareup.workflow1.internal import com.squareup.workflow1.ActionApplied import com.squareup.workflow1.ActionProcessingResult +import com.squareup.workflow1.ActionsExhausted import com.squareup.workflow1.NoopWorkflowInterceptor import com.squareup.workflow1.NullableInitBox import com.squareup.workflow1.RenderContext @@ -9,6 +10,7 @@ import com.squareup.workflow1.RuntimeConfig import com.squareup.workflow1.RuntimeConfigOptions import com.squareup.workflow1.RuntimeConfigOptions.PARTIAL_TREE_RENDERING import com.squareup.workflow1.StatefulWorkflow +import com.squareup.workflow1.StatelessWorkflow import com.squareup.workflow1.TreeSnapshot import com.squareup.workflow1.Workflow import com.squareup.workflow1.WorkflowAction @@ -27,8 +29,6 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart.LAZY -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.Channel @@ -96,7 +96,16 @@ internal class WorkflowNode( private val eventActionsChannel = Channel>(capacity = UNLIMITED) private var state: StateT - private var subtreeStateDidChange: Boolean = true + + /** + * The state of this node or that of one of our descendants changed since we last rendered. + */ + private var subtreeStateDirty: Boolean = true + + /** + * The state of this node changed since we last rendered. + */ + private var selfStateDirty: Boolean = true private val baseRenderContext = RealRenderContext( renderer = subtreeManager, @@ -207,16 +216,10 @@ internal class WorkflowNode( * a re-render, e.g. my state changed or a child state changed. * * It is an error to call this method after calling [cancel]. - * - * @return [Boolean] whether or not the queues were empty for this node and its children at the - * time of suspending. */ - @OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class) - fun onNextAction(selector: SelectBuilder): Boolean { + fun onNextAction(selector: SelectBuilder) { // Listen for any child workflow updates. - var empty = subtreeManager.onNextChildAction(selector) - - empty = empty && (eventActionsChannel.isEmpty || eventActionsChannel.isClosedForReceive) + subtreeManager.onNextChildAction(selector) // Listen for any events. with(selector) { @@ -224,7 +227,30 @@ internal class WorkflowNode( return@onReceive applyAction(action) } } - return empty + } + + /** + * Will try to apply any immediately available actions in this action queue or any of our + * children's. + * + * @param skipDirtyNodes Whether or not this should skip over any workflow nodes that are already + * 'dirty' - that is, they had their own state changed as the result of a previous action before + * the next render pass. + * + * @return [ActionProcessingResult] of the action processed, or [ActionsExhausted] if there were + * none immediately available. + */ + fun applyNextAvailableAction(skipDirtyNodes: Boolean = false): ActionProcessingResult { + if (skipDirtyNodes && selfStateDirty) return ActionsExhausted + + val result = subtreeManager.applyNextAvailableChildAction(skipDirtyNodes) + + if (result == ActionsExhausted) { + return eventActionsChannel.tryReceive().getOrNull()?.let { action -> + applyAction(action) + } ?: ActionsExhausted + } + return result } /** @@ -234,11 +260,12 @@ internal class WorkflowNode( * after calling this method. */ fun cancel(cause: CancellationException? = null) { - // No other cleanup work should be done in this function, since it will only be invoked when - // this workflow is *directly* discarded by its parent (or the host). - // If you need to do something whenever this workflow is torn down, add it to the - // invokeOnCompletion handler for the Job above. coroutineContext.cancel(cause) + lastRendering = NullableInitBox() + ( + cachedWorkflowInstance as? + StatelessWorkflow.StatelessAsStatefulWorkflow + )?.clearCache() } /** @@ -267,7 +294,7 @@ internal class WorkflowNode( if (!runtimeConfig.contains(PARTIAL_TREE_RENDERING) || !lastRendering.isInitialized || - subtreeStateDidChange + subtreeStateDirty ) { // If we haven't already updated the cached instance, better do it now! maybeUpdateCachedWorkflowInstance(workflow) @@ -287,7 +314,8 @@ internal class WorkflowNode( } // After we have rendered this subtree, we need another action in order for us to be // considered dirty again. - subtreeStateDidChange = false + subtreeStateDirty = false + selfStateDirty = false } return lastRendering.getOrThrow() @@ -296,7 +324,7 @@ internal class WorkflowNode( /** * Update props if they have changed. If that happens, then check to see if we need * to update the cached workflow instance, then call [StatefulWorkflow.onPropsChanged] and - * update the state from that. We consider any change to props as [subtreeStateDidChange] because + * update the state from that. We consider any change to props as dirty because * the props themselves are used in [StatefulWorkflow.render] (they are the 'external' part of * the state) so we must re-render. */ @@ -308,7 +336,8 @@ internal class WorkflowNode( maybeUpdateCachedWorkflowInstance(workflow) val newState = interceptedWorkflowInstance.onPropsChanged(lastProps, newProps, state) state = newState - subtreeStateDidChange = true + subtreeStateDirty = true + selfStateDirty = true } lastProps = newProps } @@ -330,8 +359,10 @@ internal class WorkflowNode( // Changing state is sticky, we pass it up if it ever changed. stateChanged = actionApplied.stateChanged || (childResult?.stateChanged ?: false) ) + // Our state changed. + selfStateDirty = actionApplied.stateChanged // Our state changed or one of our children's state changed. - subtreeStateDidChange = aggregateActionApplied.stateChanged + subtreeStateDirty = aggregateActionApplied.stateChanged return if (actionApplied.output != null || runtimeConfig.contains(PARTIAL_TREE_RENDERING) ) { @@ -344,7 +375,7 @@ internal class WorkflowNode( // // However, the root and the path down to the changed nodes must always // re-render now, so this is the implementation detail of how we get - // subtreeStateDidChange = true on that entire path to the root. + // subtreeStateDirty = true on that entire path to the root. emitAppliedActionToParent(aggregateActionApplied) } else { aggregateActionApplied diff --git a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowRunner.kt b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowRunner.kt index 9eb66bb1b..737368e12 100644 --- a/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowRunner.kt +++ b/workflow-runtime/src/commonMain/kotlin/com/squareup/workflow1/internal/WorkflowRunner.kt @@ -5,10 +5,8 @@ import com.squareup.workflow1.ActionsExhausted import com.squareup.workflow1.PropsUpdated import com.squareup.workflow1.RenderingAndSnapshot import com.squareup.workflow1.RuntimeConfig -import com.squareup.workflow1.RuntimeConfigOptions.CONFLATE_STALE_RENDERINGS import com.squareup.workflow1.TreeSnapshot import com.squareup.workflow1.Workflow -import com.squareup.workflow1.WorkflowExperimentalRuntime import com.squareup.workflow1.WorkflowInterceptor import com.squareup.workflow1.WorkflowTracer import kotlinx.coroutines.CancellationException @@ -19,7 +17,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.dropWhile import kotlinx.coroutines.flow.produceIn import kotlinx.coroutines.selects.SelectBuilder -import kotlinx.coroutines.selects.onTimeout import kotlinx.coroutines.selects.select @OptIn(ExperimentalCoroutinesApi::class) @@ -65,8 +62,8 @@ internal class WorkflowRunner( /** * Perform a render pass and a snapshot pass and return the results. * - * This method must be called before the first call to [processAction], and must be called again - * between every subsequent call to [processAction]. + * This method must be called before the first call to [waitAndProcessAction], and must be called again + * between every subsequent call to [waitAndProcessAction]. */ fun nextRendering(): RenderingAndSnapshot { return interceptor.onRenderAndSnapshot(currentProps, { props -> @@ -83,24 +80,29 @@ internal class WorkflowRunner( * and resume (breaking ties with order of declaration). Guarantees only continuing on the winning * coroutine and no others. */ - @OptIn(WorkflowExperimentalRuntime::class) - suspend fun processAction(waitForAnAction: Boolean = true): ActionProcessingResult { + suspend fun waitAndProcessAction(): ActionProcessingResult { // If waitForAction is true we block and wait until there is an action to process. return select { onPropsUpdated() // Have the workflow tree build the select to wait for an event/output from Worker. - val empty = rootNode.onNextAction(this) - if (!waitForAnAction && runtimeConfig.contains(CONFLATE_STALE_RENDERINGS) && empty) { - // With CONFLATE_STALE_RENDERINGS if there are no queued actions and we are not - // waiting for one, then return ActionsExhausted and pass the rendering on. - onTimeout(timeMillis = 0) { - // This will select synchronously since time is 0. - ActionsExhausted - } - } + rootNode.onNextAction(this) } } + /** + * Will try to apply any immediately available actions for this runtime (no suspending). + * + * @param skipDirtyNodes Whether or not this should skip over any workflow nodes that are already + * 'dirty' - that is, they had their own state changed as the result of a previous action before + * the next render pass. + * + * @return [ActionProcessingResult] of the action processed, or [ActionsExhausted] if there were + * none immediately available. + */ + fun applyNextAvailableAction(skipDirtyNodes: Boolean = false): ActionProcessingResult { + return rootNode.applyNextAvailableAction(skipDirtyNodes) + } + @OptIn(DelicateCoroutinesApi::class) private fun SelectBuilder.onPropsUpdated() { // Stop trying to read from the inputs channel after it's closed. diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/RenderWorkflowInTest.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/RenderWorkflowInTest.kt index 504203d52..c606793cc 100644 --- a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/RenderWorkflowInTest.kt +++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/RenderWorkflowInTest.kt @@ -1,12 +1,15 @@ package com.squareup.workflow1 +import app.cash.burst.Burst import com.squareup.workflow1.RuntimeConfigOptions.CONFLATE_STALE_RENDERINGS +import com.squareup.workflow1.RuntimeConfigOptions.Companion.RuntimeOptions +import com.squareup.workflow1.RuntimeConfigOptions.Companion.RuntimeOptions.DEFAULT +import com.squareup.workflow1.RuntimeConfigOptions.DRAIN_EXCLUSIVE_ACTIONS import com.squareup.workflow1.RuntimeConfigOptions.PARTIAL_TREE_RENDERING import com.squareup.workflow1.RuntimeConfigOptions.RENDER_ONLY_WHEN_STATE_CHANGES import com.squareup.workflow1.WorkflowInterceptor.RenderPassSkipped -import com.squareup.workflow1.WorkflowInterceptor.RenderPassesComplete -import com.squareup.workflow1.WorkflowInterceptor.RuntimeLoopOutcome -import com.squareup.workflow1.internal.ParameterizedTestRunner +import com.squareup.workflow1.WorkflowInterceptor.RenderingProduced +import com.squareup.workflow1.WorkflowInterceptor.RuntimeUpdate import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineExceptionHandler @@ -30,498 +33,407 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import kotlinx.coroutines.yield import okio.ByteString +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNotSame +import kotlin.test.assertNull import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class, WorkflowExperimentalRuntime::class) -class RenderWorkflowInTest { - +@Burst +class RenderWorkflowInTest( + useTracer: Boolean = false, + useUnconfined: Boolean = true, + private val runtime: RuntimeOptions = DEFAULT +) { + + private val runtimeConfig = runtime.runtimeConfig private val traces: StringBuilder = StringBuilder() - private val testTracer: WorkflowTracer = object : WorkflowTracer { - var prefix: String = "" - override fun beginSection(label: String) { - traces.appendLine("${prefix}Starting$label") - prefix += " " - } + private val testTracer: WorkflowTracer? = if (useTracer) { + object : WorkflowTracer { + var prefix: String = "" + override fun beginSection(label: String) { + traces.appendLine("${prefix}Starting$label") + prefix += " " + } - override fun endSection() { - prefix = prefix.substring(0, prefix.length - 2) - traces.appendLine("${prefix}Ending") + override fun endSection() { + prefix = prefix.substring(0, prefix.length - 2) + traces.appendLine("${prefix}Ending") + } } + } else { + null } - private val runtimes = setOf( - RuntimeConfigOptions.RENDER_PER_ACTION, - setOf(RENDER_ONLY_WHEN_STATE_CHANGES), - setOf(CONFLATE_STALE_RENDERINGS), - setOf(CONFLATE_STALE_RENDERINGS, RENDER_ONLY_WHEN_STATE_CHANGES), - setOf(RENDER_ONLY_WHEN_STATE_CHANGES, PARTIAL_TREE_RENDERING), - setOf(CONFLATE_STALE_RENDERINGS, RENDER_ONLY_WHEN_STATE_CHANGES, PARTIAL_TREE_RENDERING), - ) - - private val tracerOptions = setOf( - null, - testTracer - ) - private val myStandardTestDispatcher = StandardTestDispatcher() - private val dispatcherOptions = setOf( - UnconfinedTestDispatcher(), - myStandardTestDispatcher - ) - - private val runtimeOptions: Sequence> = - cartesianProduct( - runtimes.asSequence(), - tracerOptions.asSequence(), - dispatcherOptions.asSequence() - ) + private val dispatcherUsed = + if (useUnconfined) UnconfinedTestDispatcher() else myStandardTestDispatcher - private val runtimeTestRunner = - ParameterizedTestRunner>() + private fun advanceIfStandard(dispatcher: TestDispatcher) { + if (dispatcher == myStandardTestDispatcher) { + dispatcher.scheduler.advanceUntilIdle() + dispatcher.scheduler.runCurrent() + } + } - private fun setup() { + @BeforeTest + public fun setup() { traces.clear() } - @Test fun initial_rendering_is_calculated_synchronously() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { (runtimeConfig: RuntimeConfig, workflowTracer: WorkflowTracer?, dispatcher: TestDispatcher) -> - runTest(dispatcher) { - val props = MutableStateFlow("foo") - val workflow = Workflow.stateless { "props: $it" } - // Don't allow the workflow runtime to actually start if this is a [StandardTestDispatcher]. + @Test fun initial_rendering_is_calculated_synchronously() = runTest(dispatcherUsed) { + val props = MutableStateFlow("foo") + val workflow = Workflow.stateless { "props: $it" } + // Don't allow the workflow runtime to actually start if this is a [StandardTestDispatcher]. - val renderings = renderWorkflowIn( - workflow = workflow, - scope = backgroundScope, - props = props, - runtimeConfig = runtimeConfig, - workflowTracer = workflowTracer, - ) {} - assertEquals("props: foo", renderings.value.rendering) - } - } + val renderings = renderWorkflowIn( + workflow = workflow, + scope = backgroundScope, + props = props, + runtimeConfig = runtimeConfig, + workflowTracer = testTracer, + ) {} + assertEquals("props: foo", renderings.value.rendering) } - @Test fun initial_rendering_is_reported_through_interceptor() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { (runtimeConfig: RuntimeConfig, workflowTracer: WorkflowTracer?, dispatcher: TestDispatcher) -> - runTest(dispatcher) { - val props = MutableStateFlow("foo") - val workflow = Workflow.stateless { "props: $it" } + @Test fun initial_rendering_is_reported_through_interceptor() = runTest(dispatcherUsed) { + val props = MutableStateFlow("foo") + val workflow = Workflow.stateless { "props: $it" } - val hasReportedRendering = Mutex(locked = true) - val testInterceptor = object : WorkflowInterceptor { - override fun onRuntimeLoopTick(outcome: RuntimeLoopOutcome) { - if (outcome is RenderPassesComplete<*>) { - assertEquals("props: foo", outcome.renderingAndSnapshot.rendering) - hasReportedRendering.unlock() - } - } + val hasReportedRendering = Mutex(locked = true) + val testInterceptor = object : WorkflowInterceptor { + override fun onRuntimeUpdate(update: RuntimeUpdate) { + if (update is RenderingProduced<*>) { + assertEquals("props: foo", update.renderingAndSnapshot.rendering) + hasReportedRendering.unlock() } - renderWorkflowIn( - workflow = workflow, - scope = backgroundScope, - props = props, - interceptors = listOf(testInterceptor), - runtimeConfig = runtimeConfig, - workflowTracer = workflowTracer, - ) {} - hasReportedRendering.lock() } } + renderWorkflowIn( + workflow = workflow, + scope = backgroundScope, + props = props, + interceptors = listOf(testInterceptor), + runtimeConfig = runtimeConfig, + workflowTracer = testTracer, + ) {} + hasReportedRendering.lock() } - @Test fun modified_rendering_is_returned() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { (runtimeConfig: RuntimeConfig, workflowTracer: WorkflowTracer?, dispatcher: TestDispatcher) -> - runTest(dispatcher) { - val props = MutableStateFlow("foo") - val workflow = Workflow.stateless { "props: $it" } + @Test fun modified_rendering_is_returned() = runTest(dispatcherUsed) { + val props = MutableStateFlow("foo") + val workflow = Workflow.stateless { "props: $it" } - val interceptedRenderings = mutableListOf() - val testInterceptor = object : WorkflowInterceptor { - override fun onRuntimeLoopTick(outcome: RuntimeLoopOutcome) { - if (outcome is RenderPassesComplete<*>) { - interceptedRenderings.add(outcome.renderingAndSnapshot.rendering) - } - } + val interceptedRenderings = mutableListOf() + val testInterceptor = object : WorkflowInterceptor { + override fun onRuntimeUpdate(update: RuntimeUpdate) { + if (update is RenderingProduced<*>) { + interceptedRenderings.add(update.renderingAndSnapshot.rendering) } - - renderWorkflowIn( - workflow = workflow, - scope = backgroundScope, - props = props, - interceptors = listOf(testInterceptor), - runtimeConfig = runtimeConfig, - workflowTracer = workflowTracer, - ) {} - assertEquals(1, interceptedRenderings.size, "Should have intercepted 1 rendering.") - assertEquals( - "props: foo", - interceptedRenderings[0], - "Should intercept 'props: foo' as a rendering." - ) } } + + renderWorkflowIn( + workflow = workflow, + scope = backgroundScope, + props = props, + interceptors = listOf(testInterceptor), + runtimeConfig = runtimeConfig, + workflowTracer = testTracer, + ) {} + assertEquals(1, interceptedRenderings.size, "Should have intercepted 1 rendering.") + assertEquals( + "props: foo", + interceptedRenderings[0], + "Should intercept 'props: foo' as a rendering." + ) } - @Test fun initial_rendering_is_calculated_when_scope_cancelled_before_start() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { (runtimeConfig: RuntimeConfig, workflowTracer: WorkflowTracer?, dispatcher: TestDispatcher) -> - runTest(dispatcher) { - val props = MutableStateFlow("foo") - val workflow = Workflow.stateless { "props: $it" } - - val testScope = TestScope(dispatcher) - testScope.cancel() - val renderings = renderWorkflowIn( - workflow = workflow, - scope = testScope, - props = props, - runtimeConfig = runtimeConfig, - workflowTracer = workflowTracer, - ) {} - assertEquals("props: foo", renderings.value.rendering) - } + @Test fun initial_rendering_is_calculated_when_scope_cancelled_before_start() = + runTest(dispatcherUsed) { + val props = MutableStateFlow("foo") + val workflow = Workflow.stateless { "props: $it" } + + val testScope = TestScope(dispatcherUsed) + testScope.cancel() + val renderings = renderWorkflowIn( + workflow = workflow, + scope = testScope, + props = props, + runtimeConfig = runtimeConfig, + workflowTracer = testTracer, + ) {} + assertEquals("props: foo", renderings.value.rendering) } - } @Test - fun `side_effects_from_initial_rendering_in_root_workflow_are_never_started_when_scope_cancelled_before_start`() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { (runtimeConfig: RuntimeConfig, workflowTracer: WorkflowTracer?, dispatcher: TestDispatcher) -> - runTest(dispatcher) { - var sideEffectWasRan = false - val workflow = Workflow.stateless { - runningSideEffect("test") { - sideEffectWasRan = true - } + fun `side_effects_from_initial_rendering_in_root_workflow_are_never_started_when_scope_cancelled_before_start`() = + runTest(dispatcherUsed) { + var sideEffectWasRan = false + val workflow = Workflow.stateless { + runningSideEffect("test") { + sideEffectWasRan = true } - - val testScope = TestScope(dispatcher) - testScope.cancel() - renderWorkflowIn( - workflow, - testScope, - MutableStateFlow(Unit), - runtimeConfig = runtimeConfig, - workflowTracer = workflowTracer, - ) {} - advanceIfStandard(dispatcher) - - assertFalse(sideEffectWasRan) } + + val testScope = TestScope(dispatcherUsed) + testScope.cancel() + renderWorkflowIn( + workflow, + testScope, + MutableStateFlow(Unit), + runtimeConfig = runtimeConfig, + workflowTracer = testTracer, + ) {} + advanceIfStandard(dispatcherUsed) + + assertFalse(sideEffectWasRan) } - } @Test - fun `side_effects_from_initial_rendering_in_non_root_workflow_are_never_started_when_scope_cancelled_before_start`() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { (runtimeConfig: RuntimeConfig, workflowTracer: WorkflowTracer?, dispatcher: TestDispatcher) -> - runTest(dispatcher) { - var sideEffectWasRan = false - val childWorkflow = Workflow.stateless { - runningSideEffect("test") { - sideEffectWasRan = true - } + fun `side_effects_from_initial_rendering_in_non_root_workflow_are_never_started_when_scope_cancelled_before_start`() = + runTest(dispatcherUsed) { + var sideEffectWasRan = false + val childWorkflow = Workflow.stateless { + runningSideEffect("test") { + sideEffectWasRan = true } - val workflow = Workflow.stateless { - renderChild(childWorkflow) - } - - val testScope = TestScope(dispatcher) - testScope.cancel() - renderWorkflowIn( - workflow = workflow, - scope = testScope, - props = MutableStateFlow(Unit), - runtimeConfig = runtimeConfig, - workflowTracer = workflowTracer, - ) {} - advanceIfStandard(dispatcher) - - assertFalse(sideEffectWasRan) } + val workflow = Workflow.stateless { + renderChild(childWorkflow) + } + + val testScope = TestScope(dispatcherUsed) + testScope.cancel() + renderWorkflowIn( + workflow = workflow, + scope = testScope, + props = MutableStateFlow(Unit), + runtimeConfig = runtimeConfig, + workflowTracer = testTracer, + ) {} + advanceIfStandard(dispatcherUsed) + + assertFalse(sideEffectWasRan) } - } - @Test fun new_renderings_are_emitted_on_update() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { (runtimeConfig: RuntimeConfig, workflowTracer: WorkflowTracer?, dispatcher: TestDispatcher) -> - runTest(dispatcher) { - val props = MutableStateFlow("foo") - val workflow = Workflow.stateless { "props: $it" } - val renderings = renderWorkflowIn( - workflow = workflow, - scope = backgroundScope, - props = props, - runtimeConfig = runtimeConfig, - workflowTracer = workflowTracer, - ) {} - advanceIfStandard(dispatcher) + @Test fun new_renderings_are_emitted_on_update() = runTest(dispatcherUsed) { + val props = MutableStateFlow("foo") + val workflow = Workflow.stateless { "props: $it" } + val renderings = renderWorkflowIn( + workflow = workflow, + scope = backgroundScope, + props = props, + runtimeConfig = runtimeConfig, + workflowTracer = testTracer, + ) {} + advanceIfStandard(dispatcherUsed) - assertEquals("props: foo", renderings.value.rendering) + assertEquals("props: foo", renderings.value.rendering) - props.value = "bar" - advanceIfStandard(dispatcher) + props.value = "bar" + advanceIfStandard(dispatcherUsed) - assertEquals("props: bar", renderings.value.rendering) - } - } + assertEquals("props: bar", renderings.value.rendering) } - @Test fun new_renderings_are_emitted_to_interceptor() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { (runtimeConfig: RuntimeConfig, workflowTracer: WorkflowTracer?, dispatcher: TestDispatcher) -> - runTest(dispatcher) { - val props = MutableStateFlow("foo") - val workflow = Workflow.stateless { "props: $it" } + @Test fun new_renderings_are_emitted_to_interceptor() = runTest(dispatcherUsed) { + val props = MutableStateFlow("foo") + val workflow = Workflow.stateless { "props: $it" } - val interceptedRenderings = mutableListOf() - val testInterceptor = object : WorkflowInterceptor { - override fun onRuntimeLoopTick(outcome: RuntimeLoopOutcome) { - if (outcome is RenderPassesComplete<*>) { - interceptedRenderings.add(outcome.renderingAndSnapshot.rendering) - } - } + val interceptedRenderings = mutableListOf() + val testInterceptor = object : WorkflowInterceptor { + override fun onRuntimeUpdate(update: RuntimeUpdate) { + if (update is RenderingProduced<*>) { + interceptedRenderings.add(update.renderingAndSnapshot.rendering) } - - renderWorkflowIn( - workflow = workflow, - scope = backgroundScope, - props = props, - interceptors = listOf(testInterceptor), - runtimeConfig = runtimeConfig, - workflowTracer = workflowTracer, - ) {} - advanceIfStandard(dispatcher) - - assertEquals(1, interceptedRenderings.size, "Should have intercepted 1 rendering.") - assertEquals( - "props: foo", - interceptedRenderings[0], - "Should intercept 'props: foo' as a rendering." - ) - - props.value = "bar" - advanceIfStandard(dispatcher) - - assertEquals(2, interceptedRenderings.size, "Should have intercepted 2 rendering.") - assertEquals( - "props: bar", - interceptedRenderings[1], - "Should intercept 'props: bar' as a rendering." - ) } } - } - private val runtimeMatrix: Sequence> = - cartesianProduct( - runtimes.asSequence(), - runtimes.asSequence(), - dispatcherOptions.asSequence(), + renderWorkflowIn( + workflow = workflow, + scope = backgroundScope, + props = props, + interceptors = listOf(testInterceptor), + runtimeConfig = runtimeConfig, + workflowTracer = testTracer, + ) {} + advanceIfStandard(dispatcherUsed) + + assertEquals(1, interceptedRenderings.size, "Should have intercepted 1 rendering.") + assertEquals( + "props: foo", + interceptedRenderings[0], + "Should intercept 'props: foo' as a rendering." ) - private val runtimeMatrixTestRunner = - ParameterizedTestRunner>() - - @Test fun saves_to_and_restores_from_snapshot() { - runtimeMatrixTestRunner.runParametrizedTest( - paramSource = runtimeMatrix, - before = ::setup, - ) { (runtimeConfig1, runtimeConfig2, dispatcher) -> - runTest(dispatcher) { - val workflow = Workflow.stateful Unit>>( - initialState = { _, snapshot -> - snapshot?.bytes?.parse { it.readUtf8WithLength() } ?: "initial state" - }, - snapshot = { state -> - Snapshot.write { it.writeUtf8WithLength(state) } - }, - render = { _, renderState -> - Pair( - renderState, - { newState -> actionSink.send(action("") { state = newState }) } - ) - } - ) - val props = MutableStateFlow(Unit) - val renderings = renderWorkflowIn( - workflow = workflow, - scope = backgroundScope, - props = props, - runtimeConfig = runtimeConfig1, - workflowTracer = null, - ) {} - advanceIfStandard(dispatcher) + props.value = "bar" + advanceIfStandard(dispatcherUsed) - // Interact with the workflow to change the state. - renderings.value.rendering.let { (state, updateState) -> - runtimeMatrixTestRunner.assertEquals("initial state", state) - updateState("updated state") - } - advanceIfStandard(dispatcher) + assertEquals(2, interceptedRenderings.size, "Should have intercepted 2 rendering.") + assertEquals( + "props: bar", + interceptedRenderings[1], + "Should intercept 'props: bar' as a rendering." + ) + } - val snapshot = renderings.value.let { (rendering, snapshot) -> - val (state, updateState) = rendering - runtimeMatrixTestRunner.assertEquals("updated state", state) - updateState("ignored rendering") - return@let snapshot - } - advanceIfStandard(dispatcher) - - // Create a new scope to launch a second runtime to restore. - val restoreScope = TestScope(dispatcher) - val restoredRenderings = - renderWorkflowIn( - workflow = workflow, - scope = restoreScope, - props = props, - initialSnapshot = snapshot, - workflowTracer = null, - runtimeConfig = runtimeConfig2 - ) {} - advanceIfStandard(dispatcher) - runtimeMatrixTestRunner.assertEquals( - "updated state", - restoredRenderings.value.rendering.first + @Test fun saves_to_and_restores_from_snapshot( + runtime2: RuntimeOptions = DEFAULT + ) = runTest(dispatcherUsed) { + val workflow = Workflow.stateful Unit>>( + initialState = { _, snapshot -> + snapshot?.bytes?.parse { it.readUtf8WithLength() } ?: "initial state" + }, + snapshot = { state -> + Snapshot.write { it.writeUtf8WithLength(state) } + }, + render = { _, renderState -> + Pair( + renderState, + { newState -> actionSink.send(action("") { state = newState }) } ) } + ) + val props = MutableStateFlow(Unit) + val renderings = renderWorkflowIn( + workflow = workflow, + scope = backgroundScope, + props = props, + runtimeConfig = runtimeConfig, + workflowTracer = null, + ) {} + advanceIfStandard(dispatcherUsed) + + // Interact with the workflow to change the state. + renderings.value.rendering.let { (state, updateState) -> + assertEquals("initial state", state) + updateState("updated state") + } + advanceIfStandard(dispatcherUsed) + + val snapshot = renderings.value.let { (rendering, snapshot) -> + val (state, updateState) = rendering + assertEquals("updated state", state) + updateState("ignored rendering") + return@let snapshot } + advanceIfStandard(dispatcherUsed) + + // Create a new scope to launch a second runtime to restore. + val restoreScope = TestScope(dispatcherUsed) + val restoredRenderings = + renderWorkflowIn( + workflow = workflow, + scope = restoreScope, + props = props, + initialSnapshot = snapshot, + workflowTracer = null, + runtimeConfig = runtime2.runtimeConfig + ) {} + advanceIfStandard(dispatcherUsed) + assertEquals( + "updated state", + restoredRenderings.value.rendering.first + ) } // https://github.com/square/workflow-kotlin/issues/223 - @Test fun snapshots_are_lazy() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { (runtimeConfig: RuntimeConfig, workflowTracer: WorkflowTracer?, dispatcher: TestDispatcher) -> - runTest(dispatcher) { - lateinit var sink: Sink - var snapped = false - - val workflow = Workflow.stateful( - initialState = { _, _ -> "unchanging state" }, - snapshot = { - Snapshot.of { - snapped = true - ByteString.of(1) - } - }, - render = { _, renderState -> - sink = actionSink.contraMap { action("") { state = it } } - renderState - } - ) - val props = MutableStateFlow(Unit) - val renderings = renderWorkflowIn( - workflow = workflow, - scope = backgroundScope, - props = props, - runtimeConfig = runtimeConfig, - workflowTracer = workflowTracer, - ) {} - advanceIfStandard(dispatcher) - - val emitted = mutableListOf>() - val collectionJob = launch { - renderings.collect { emitted += it } - } - advanceIfStandard(dispatcher) - - if (runtimeConfig.contains(RENDER_ONLY_WHEN_STATE_CHANGES)) { - // we have to change state then or it won't render. - sink.send("changing state") - } else { - sink.send("unchanging state") - } - advanceIfStandard(dispatcher) - - if (runtimeConfig.contains(RENDER_ONLY_WHEN_STATE_CHANGES)) { - // we have to change state then or it won't render. - sink.send("changing state, again") - } else { - sink.send("unchanging state") + @Test fun snapshots_are_lazy() = runTest(dispatcherUsed) { + lateinit var sink: Sink + var snapped = false + + val workflow = Workflow.stateful( + initialState = { _, _ -> "unchanging state" }, + snapshot = { + Snapshot.of { + snapped = true + ByteString.of(1) } - advanceIfStandard(dispatcher) + }, + render = { _, renderState -> + sink = actionSink.contraMap { action("") { state = it } } + renderState + } + ) + val props = MutableStateFlow(Unit) + val renderings = renderWorkflowIn( + workflow = workflow, + scope = backgroundScope, + props = props, + runtimeConfig = runtimeConfig, + workflowTracer = testTracer, + ) {} + advanceIfStandard(dispatcherUsed) - collectionJob.cancel() + val emitted = mutableListOf>() + val collectionJob = launch { + renderings.collect { emitted += it } + } + advanceIfStandard(dispatcherUsed) - assertFalse(snapped) - assertNotSame( - emitted[0].snapshot.workflowSnapshot, - emitted[1].snapshot.workflowSnapshot - ) - assertNotSame( - emitted[1].snapshot.workflowSnapshot, - emitted[2].snapshot.workflowSnapshot - ) - } + if (runtimeConfig.contains(RENDER_ONLY_WHEN_STATE_CHANGES)) { + // we have to change state then or it won't render. + sink.send("changing state") + } else { + sink.send("unchanging state") } - } + advanceIfStandard(dispatcherUsed) - @Test fun onOutput_called_when_output_emitted() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { (runtimeConfig: RuntimeConfig, workflowTracer: WorkflowTracer?, dispatcher: TestDispatcher) -> - runTest(dispatcher) { - val trigger = Channel() - val workflow = Workflow.stateless { - runningWorker( - trigger.receiveAsFlow() - .asWorker() - ) { action("") { setOutput(it) } } - } - val receivedOutputs = mutableListOf() - renderWorkflowIn( - workflow = workflow, - scope = backgroundScope, - props = MutableStateFlow(Unit), - runtimeConfig = runtimeConfig, - workflowTracer = workflowTracer, - ) { - receivedOutputs += it - } - advanceIfStandard(dispatcher) - assertTrue(receivedOutputs.isEmpty()) + if (runtimeConfig.contains(RENDER_ONLY_WHEN_STATE_CHANGES)) { + // we have to change state then or it won't render. + sink.send("changing state, again") + } else { + sink.send("unchanging state") + } + advanceIfStandard(dispatcherUsed) - assertTrue(trigger.trySend("foo").isSuccess) - advanceIfStandard(dispatcher) - assertEquals(listOf("foo"), receivedOutputs) + collectionJob.cancel() - assertTrue(trigger.trySend("bar").isSuccess) - advanceIfStandard(dispatcher) - assertEquals(listOf("foo", "bar"), receivedOutputs) - } - } + assertFalse(snapped) + assertNotSame( + emitted[0].snapshot.workflowSnapshot, + emitted[1].snapshot.workflowSnapshot + ) + assertNotSame( + emitted[1].snapshot.workflowSnapshot, + emitted[2].snapshot.workflowSnapshot + ) } - private fun advanceIfStandard(dispatcher: TestDispatcher) { - if (dispatcher == myStandardTestDispatcher) { - dispatcher.scheduler.advanceUntilIdle() - dispatcher.scheduler.runCurrent() + @Test fun onOutput_called_when_output_emitted() = runTest(dispatcherUsed) { + val trigger = Channel() + val workflow = Workflow.stateless { + runningWorker( + trigger.receiveAsFlow() + .asWorker() + ) { action("") { setOutput(it) } } } + val receivedOutputs = mutableListOf() + renderWorkflowIn( + workflow = workflow, + scope = backgroundScope, + props = MutableStateFlow(Unit), + runtimeConfig = runtimeConfig, + workflowTracer = testTracer, + ) { + receivedOutputs += it + } + advanceIfStandard(dispatcherUsed) + assertTrue(receivedOutputs.isEmpty()) + + assertTrue(trigger.trySend("foo").isSuccess) + advanceIfStandard(dispatcherUsed) + assertEquals(listOf("foo"), receivedOutputs) + + assertTrue(trigger.trySend("bar").isSuccess) + advanceIfStandard(dispatcherUsed) + assertEquals(listOf("foo", "bar"), receivedOutputs) } /** @@ -535,53 +447,47 @@ class RenderWorkflowInTest { * See [onOutput_called_after_rendering_emitted_and_collected] for alternate behaviour with * a different dispatcher for the runtime. */ - @Test fun onOutput_called_after_rendering_emitted() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { (runtimeConfig: RuntimeConfig, workflowTracer: WorkflowTracer?, dispatcher: TestDispatcher) -> - runTest(dispatcher) { - val trigger = Channel() - val workflow = Workflow.stateful( - initialState = "initial", - render = { renderState -> - runningWorker( - trigger.receiveAsFlow() - .asWorker() - ) { - action("") { - state = it - setOutput(it) - } + @Test fun onOutput_called_after_rendering_emitted() = + runTest(dispatcherUsed) { + val trigger = Channel() + val workflow = Workflow.stateful( + initialState = "initial", + render = { renderState -> + runningWorker( + trigger.receiveAsFlow() + .asWorker() + ) { + action("") { + state = it + setOutput(it) } - renderState } - ) - - val receivedOutputs = mutableListOf() - lateinit var renderings: StateFlow> - renderings = renderWorkflowIn( - workflow = workflow, - scope = backgroundScope, - props = MutableStateFlow(Unit), - runtimeConfig = runtimeConfig, - workflowTracer = workflowTracer, - ) { it: String -> - receivedOutputs += it - // The value of the updated rendering has already been set by the time onOutput is - // called - assertEquals(it, renderings.value.rendering) + renderState } - advanceIfStandard(dispatcher) + ) + + val receivedOutputs = mutableListOf() + lateinit var renderings: StateFlow> + renderings = renderWorkflowIn( + workflow = workflow, + scope = backgroundScope, + props = MutableStateFlow(Unit), + runtimeConfig = runtimeConfig, + workflowTracer = testTracer, + ) { it: String -> + receivedOutputs += it + // The value of the updated rendering has already been set by the time onOutput is + // called + assertEquals(it, renderings.value.rendering) + } + advanceIfStandard(dispatcherUsed) - assertTrue(receivedOutputs.isEmpty()) + assertTrue(receivedOutputs.isEmpty()) - assertTrue(trigger.trySend("foo").isSuccess) - advanceIfStandard(dispatcher) - assertEquals(listOf("foo"), receivedOutputs) - } + assertTrue(trigger.trySend("foo").isSuccess) + advanceIfStandard(dispatcherUsed) + assertEquals(listOf("foo"), receivedOutputs) } - } /** * A different form of [onOutput_called_after_rendering_emitted]. Here we launch the workflow @@ -596,11 +502,8 @@ class RenderWorkflowInTest { * Then when we let the runtime's scheduler go ahead, it will have already been populated. */ @Test fun onOutput_called_after_rendering_emitted_and_collected() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions.filter { it.third != myStandardTestDispatcher }, - before = ::setup, - ) { (runtimeConfig: RuntimeConfig, workflowTracer: WorkflowTracer?, dispatcher: TestDispatcher) -> - runTest(dispatcher) { + if (dispatcherUsed != myStandardTestDispatcher) { + runTest(dispatcherUsed) { val trigger = Channel() val workflow = Workflow.stateful( initialState = "initial", @@ -627,7 +530,7 @@ class RenderWorkflowInTest { scope = testScope, props = MutableStateFlow(Unit), runtimeConfig = runtimeConfig, - workflowTracer = workflowTracer, + workflowTracer = testTracer, ) { it: String -> // The list collecting the renderings already contains it by the time onOutput is fired. assertTrue(emittedRenderings.contains(it)) @@ -654,619 +557,526 @@ class RenderWorkflowInTest { } } - @Test fun tracer_includes_expected_sections() = runTest(UnconfinedTestDispatcher()) { - // Only test default so we only have one 'golden value' to assert against. - // We are only testing the tracer correctness here, which should be agnostic of runtime. - // We include 'tracers' in the other test to test against unexpected side effects. - val runtimeConfig = RuntimeConfigOptions.DEFAULT_CONFIG - val workflowTracer = testTracer - setup() - val trigger = Channel() - val workflow = Workflow.stateful( - initialState = "initial", - render = { renderState -> - runningWorker( - trigger.receiveAsFlow() - .asWorker() - ) { - action("") { - state = it - setOutput(it) - } - } - renderState - } - ) - - val emittedRenderings = mutableListOf() - val receivedOutputs = mutableListOf() - val renderings = renderWorkflowIn( - workflow = workflow, - scope = backgroundScope, - props = MutableStateFlow(Unit), - runtimeConfig = runtimeConfig, - workflowTracer = workflowTracer, - onOutput = {} - ) - assertTrue(receivedOutputs.isEmpty()) - - val collectionJob = launch { - renderings.collect { rendering: RenderingAndSnapshot -> - emittedRenderings += rendering.rendering - } - } - - assertTrue(trigger.trySend("foo").isSuccess) - - assertEquals(EXPECTED_TRACE, traces.toString().trim()) + @Test fun tracer_includes_expected_sections() { + if (runtime == DEFAULT && testTracer != null) { + runTest(UnconfinedTestDispatcher()) { + // Only test default so we only have one 'golden value' to assert against. + // We are only testing the tracer correctness here, which should be agnostic of runtime. + // We include 'tracers' in the other test to test against unexpected side effects. - collectionJob.cancel() - } + val trigger = Channel() + val workflow = Workflow.stateful( + initialState = "initial", + render = { renderState -> + runningWorker( + trigger.receiveAsFlow() + .asWorker() + ) { + action("") { + state = it + setOutput(it) + } + } + renderState + } + ) - @Test fun onOutput_is_not_called_when_no_output_emitted() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { (runtimeConfig: RuntimeConfig, workflowTracer: WorkflowTracer?, dispatcher: TestDispatcher) -> - runTest(dispatcher) { - val workflow = Workflow.stateless { props -> props } - var onOutputCalls = 0 - val props = MutableStateFlow(0) + val emittedRenderings = mutableListOf() + val receivedOutputs = mutableListOf() val renderings = renderWorkflowIn( workflow = workflow, scope = backgroundScope, - props = props, + props = MutableStateFlow(Unit), runtimeConfig = runtimeConfig, - workflowTracer = workflowTracer, - ) { onOutputCalls++ } - advanceIfStandard(dispatcher) - assertEquals(0, renderings.value.rendering) - assertEquals(0, onOutputCalls) - - props.value = 1 - advanceIfStandard(dispatcher) - assertEquals(1, renderings.value.rendering) - assertEquals(0, onOutputCalls) - - props.value = 2 - advanceIfStandard(dispatcher) - assertEquals(2, renderings.value.rendering) - assertEquals(0, onOutputCalls) + workflowTracer = testTracer, + onOutput = {} + ) + assertTrue(receivedOutputs.isEmpty()) + + val collectionJob = launch { + renderings.collect { rendering: RenderingAndSnapshot -> + emittedRenderings += rendering.rendering + } + } + + assertTrue(trigger.trySend("foo").isSuccess) + + assertEquals(EXPECTED_TRACE, traces.toString().trim()) + + collectionJob.cancel() } } } + @Test fun onOutput_is_not_called_when_no_output_emitted() = + runTest(dispatcherUsed) { + val workflow = Workflow.stateless { props -> props } + var onOutputCalls = 0 + val props = MutableStateFlow(0) + val renderings = renderWorkflowIn( + workflow = workflow, + scope = backgroundScope, + props = props, + runtimeConfig = runtimeConfig, + workflowTracer = testTracer, + ) { onOutputCalls++ } + advanceIfStandard(dispatcherUsed) + assertEquals(0, renderings.value.rendering) + assertEquals(0, onOutputCalls) + + props.value = 1 + advanceIfStandard(dispatcherUsed) + assertEquals(1, renderings.value.rendering) + assertEquals(0, onOutputCalls) + + props.value = 2 + advanceIfStandard(dispatcherUsed) + assertEquals(2, renderings.value.rendering) + assertEquals(0, onOutputCalls) + } + /** * Since the initial render occurs before launching the coroutine, an exception thrown from it * doesn't implicitly cancel the scope. If it did, the reception would be reported twice: once to * the caller, and once to the scope. */ - @Test fun exception_from_initial_render_does_not_fail_parent_scope() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { (runtimeConfig: RuntimeConfig, workflowTracer: WorkflowTracer?, dispatcher: TestDispatcher) -> - runTest(dispatcher) { - val workflow = Workflow.stateless { - throw ExpectedException() - } - assertFailsWith { - renderWorkflowIn( - workflow = workflow, - scope = backgroundScope, - props = MutableStateFlow(Unit), - runtimeConfig = runtimeConfig, - workflowTracer = workflowTracer, - ) {} - } - assertTrue(backgroundScope.isActive) + @Test fun exception_from_initial_render_does_not_fail_parent_scope() = + runTest(dispatcherUsed) { + val workflow = Workflow.stateless { + throw ExpectedException() } - } - } - - @Test - fun `side_effects_from_initial_rendering_in_root_workflow_are_never_started_when_initial_render_of_root_workflow_fails`() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { (runtimeConfig: RuntimeConfig, workflowTracer: WorkflowTracer?, dispatcher: TestDispatcher) -> - runTest(dispatcher) { - var sideEffectWasRan = false - val workflow = Workflow.stateless { - runningSideEffect("test") { - sideEffectWasRan = true - } - throw ExpectedException() - } - - assertFailsWith { - renderWorkflowIn( - workflow = workflow, - scope = backgroundScope, - props = MutableStateFlow(Unit), - runtimeConfig = runtimeConfig, - workflowTracer = workflowTracer, - ) {} - } - assertFalse(sideEffectWasRan) + assertFailsWith { + renderWorkflowIn( + workflow = workflow, + scope = backgroundScope, + props = MutableStateFlow(Unit), + runtimeConfig = runtimeConfig, + workflowTracer = testTracer, + ) {} } + assertTrue(backgroundScope.isActive) } - } @Test - fun `side_effects_from_initial_rendering_in_non_root_workflow_are_cancelled_when_initial_render_of_root_workflow_fails`() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { (runtimeConfig: RuntimeConfig, workflowTracer: WorkflowTracer?, dispatcher: TestDispatcher) -> - runTest(dispatcher) { - var sideEffectWasRan = false - var cancellationException: Throwable? = null - val childWorkflow = Workflow.stateless { - runningSideEffect("test") { - sideEffectWasRan = true - suspendCancellableCoroutine { continuation -> - continuation.invokeOnCancellation { cause -> cancellationException = cause } - } - } - } - val workflow = Workflow.stateless { - renderChild(childWorkflow) - throw ExpectedException() + fun `side_effects_from_initial_rendering_in_root_workflow_are_never_started_when_initial_render_of_root_workflow_fails`() = + runTest(dispatcherUsed) { + var sideEffectWasRan = false + val workflow = Workflow.stateless { + runningSideEffect("test") { + sideEffectWasRan = true } + throw ExpectedException() + } - assertFailsWith { - renderWorkflowIn( - workflow = workflow, - scope = backgroundScope, - props = MutableStateFlow(Unit), - runtimeConfig = runtimeConfig, - workflowTracer = workflowTracer, - ) {} - } - advanceIfStandard(dispatcher) - if (dispatcher != myStandardTestDispatcher) { - // Side effect will never actually be started unless the dispatcher is eager. - assertTrue(sideEffectWasRan) - assertNotNull(cancellationException) - val realCause = generateSequence(cancellationException) { it.cause } - .firstOrNull { it !is CancellationException } - assertTrue(realCause is ExpectedException) - } + assertFailsWith { + renderWorkflowIn( + workflow = workflow, + scope = backgroundScope, + props = MutableStateFlow(Unit), + runtimeConfig = runtimeConfig, + workflowTracer = testTracer, + ) {} } + assertFalse(sideEffectWasRan) } - } @Test - fun `side_effects_from_initial_rendering_in_non_root_workflow_are_never_started_when_initial_render_of_non_root_workflow_fails`() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { (runtimeConfig: RuntimeConfig, workflowTracer: WorkflowTracer?, dispatcher: TestDispatcher) -> - runTest(dispatcher) { - var sideEffectWasRan = false - val childWorkflow = Workflow.stateless { - runningSideEffect("test") { - sideEffectWasRan = true + fun `side_effects_from_initial_rendering_in_non_root_workflow_are_cancelled_when_initial_render_of_root_workflow_fails`() = + runTest(dispatcherUsed) { + var sideEffectWasRan = false + var cancellationException: Throwable? = null + val childWorkflow = Workflow.stateless { + runningSideEffect("test") { + sideEffectWasRan = true + suspendCancellableCoroutine { continuation -> + continuation.invokeOnCancellation { cause -> cancellationException = cause } } - throw ExpectedException() } - val workflow = Workflow.stateless { - renderChild(childWorkflow) - } - - assertFailsWith { - renderWorkflowIn( - workflow = workflow, - scope = backgroundScope, - props = MutableStateFlow(Unit), - runtimeConfig = runtimeConfig, - workflowTracer = workflowTracer, - ) {} - } - assertFalse(sideEffectWasRan) } - } - } + val workflow = Workflow.stateless { + renderChild(childWorkflow) + throw ExpectedException() + } - @Test fun exception_from_non_initial_render_fails_parent_scope() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { (runtimeConfig: RuntimeConfig, workflowTracer: WorkflowTracer?, dispatcher: TestDispatcher) -> - runTest(dispatcher) { - val trigger = CompletableDeferred() - // Throws an exception when trigger is completed. - val workflow = Workflow.stateful( - initialState = { false }, - render = { _, throwNow -> - runningWorker(Worker.from { trigger.await() }) { action("") { state = true } } - if (throwNow) { - throw ExpectedException() - } - } - ) - val testScope = TestScope(dispatcher) + assertFailsWith { renderWorkflowIn( workflow = workflow, - scope = testScope, + scope = backgroundScope, props = MutableStateFlow(Unit), runtimeConfig = runtimeConfig, - workflowTracer = workflowTracer, + workflowTracer = testTracer, ) {} - - assertTrue(testScope.isActive) - - trigger.complete(Unit) - advanceIfStandard(dispatcher) - - assertFalse(testScope.isActive) + } + advanceIfStandard(dispatcherUsed) + if (dispatcherUsed != myStandardTestDispatcher) { + // Side effect will never actually be started unless the dispatcher is eager. + assertTrue(sideEffectWasRan) + assertNotNull(cancellationException) + val realCause = generateSequence(cancellationException) { it.cause } + .firstOrNull { it !is CancellationException } + assertTrue(realCause is ExpectedException) } } - } - @Test fun exception_from_action_fails_parent_scope() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { (runtimeConfig: RuntimeConfig, workflowTracer: WorkflowTracer?, dispatcher: TestDispatcher) -> - runTest(dispatcher) { - val trigger = CompletableDeferred() - // Throws an exception when trigger is completed. - val workflow = Workflow.stateless { - runningWorker(Worker.from { trigger.await() }) { - action("") { - throw ExpectedException() - } - } + @Test + fun `side_effects_from_initial_rendering_in_non_root_workflow_are_never_started_when_initial_render_of_non_root_workflow_fails`() = + runTest(dispatcherUsed) { + var sideEffectWasRan = false + val childWorkflow = Workflow.stateless { + runningSideEffect("test") { + sideEffectWasRan = true } - val testScope = TestScope(dispatcher) + throw ExpectedException() + } + val workflow = Workflow.stateless { + renderChild(childWorkflow) + } + + assertFailsWith { renderWorkflowIn( workflow = workflow, - scope = testScope, + scope = backgroundScope, props = MutableStateFlow(Unit), runtimeConfig = runtimeConfig, - workflowTracer = workflowTracer, + workflowTracer = testTracer, ) {} - - assertTrue(testScope.isActive) - - trigger.complete(Unit) - advanceIfStandard(dispatcher) - - assertFalse(testScope.isActive) } + assertFalse(sideEffectWasRan) } - } - @Test fun cancelling_scope_cancels_runtime() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { (runtimeConfig: RuntimeConfig, workflowTracer: WorkflowTracer?, dispatcher: TestDispatcher) -> - runTest(dispatcher) { - var cancellationException: Throwable? = null - val workflow = Workflow.stateless { - runningSideEffect(key = "test1") { - suspendCancellableCoroutine { continuation -> - continuation.invokeOnCancellation { cause -> cancellationException = cause } - } + @Test fun exception_from_non_initial_render_fails_parent_scope() = + runTest(dispatcherUsed) { + val trigger = CompletableDeferred() + // Throws an exception when trigger is completed. + val workflow = Workflow.stateful( + initialState = { false }, + render = { _, throwNow -> + runningWorker(Worker.from { trigger.await() }) { action("") { state = true } } + if (throwNow) { + throw ExpectedException() } } - val testScope = TestScope(dispatcher) - renderWorkflowIn( - workflow = workflow, - scope = testScope, - props = MutableStateFlow(Unit), - runtimeConfig = runtimeConfig, - workflowTracer = workflowTracer, - ) {} - assertNull(cancellationException) - assertTrue(testScope.isActive) - advanceIfStandard(dispatcher) - - testScope.cancel() - - advanceIfStandard(dispatcher) - - assertTrue(cancellationException is CancellationException) - assertNull(cancellationException!!.cause) - } + ) + val testScope = TestScope(dispatcherUsed) + renderWorkflowIn( + workflow = workflow, + scope = testScope, + props = MutableStateFlow(Unit), + runtimeConfig = runtimeConfig, + workflowTracer = testTracer, + ) {} + + assertTrue(testScope.isActive) + + trigger.complete(Unit) + advanceIfStandard(dispatcherUsed) + + assertFalse(testScope.isActive) } - } - @Test fun cancelling_scope_in_action_cancels_runtime_and_does_not_render_again() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { (runtimeConfig: RuntimeConfig, workflowTracer: WorkflowTracer?, dispatcher: TestDispatcher) -> - runTest(dispatcher) { - val testScope = TestScope(dispatcher) - val trigger = CompletableDeferred() - var renderCount = 0 - val workflow = Workflow.stateless { - renderCount++ - runningWorker(Worker.from { trigger.await() }) { - action("") { - testScope.cancel() - } + @Test fun exception_from_action_fails_parent_scope() = + runTest(dispatcherUsed) { + val trigger = CompletableDeferred() + // Throws an exception when trigger is completed. + val workflow = Workflow.stateless { + runningWorker(Worker.from { trigger.await() }) { + action("") { + throw ExpectedException() } } - renderWorkflowIn( - workflow = workflow, - scope = testScope, - props = MutableStateFlow(Unit), - runtimeConfig = runtimeConfig, - workflowTracer = workflowTracer, - ) {} - advanceIfStandard(dispatcher) + } + val testScope = TestScope(dispatcherUsed) + renderWorkflowIn( + workflow = workflow, + scope = testScope, + props = MutableStateFlow(Unit), + runtimeConfig = runtimeConfig, + workflowTracer = testTracer, + ) {} - assertTrue(testScope.isActive) - assertTrue(renderCount == 1) + assertTrue(testScope.isActive) - trigger.complete(Unit) + trigger.complete(Unit) + advanceIfStandard(dispatcherUsed) - advanceIfStandard(dispatcher) + assertFalse(testScope.isActive) + } - assertFalse(testScope.isActive) - assertEquals( - 1, - renderCount, - "Should not render after CoroutineScope is canceled." - ) + @Test fun cancelling_scope_cancels_runtime() = + runTest(dispatcherUsed) { + var cancellationException: Throwable? = null + val workflow = Workflow.stateless { + runningSideEffect(key = "test1") { + suspendCancellableCoroutine { continuation -> + continuation.invokeOnCancellation { cause -> cancellationException = cause } + } + } } + val testScope = TestScope(dispatcherUsed) + renderWorkflowIn( + workflow = workflow, + scope = testScope, + props = MutableStateFlow(Unit), + runtimeConfig = runtimeConfig, + workflowTracer = testTracer, + ) {} + assertNull(cancellationException) + assertTrue(testScope.isActive) + advanceIfStandard(dispatcherUsed) + + testScope.cancel() + + advanceIfStandard(dispatcherUsed) + + assertTrue(cancellationException is CancellationException) + assertNull(cancellationException!!.cause) } - } - @Test fun failing_scope_cancels_runtime() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { (runtimeConfig: RuntimeConfig, workflowTracer: WorkflowTracer?, dispatcher: TestDispatcher) -> - runTest(dispatcher) { - var cancellationException: Throwable? = null - val workflow = Workflow.stateless { - runningSideEffect(key = "failing") { - suspendCancellableCoroutine { continuation -> - continuation.invokeOnCancellation { cause -> cancellationException = cause } - } + @Test fun cancelling_scope_in_action_cancels_runtime_and_does_not_render_again() = + runTest(dispatcherUsed) { + val testScope = TestScope(dispatcherUsed) + val trigger = CompletableDeferred() + var renderCount = 0 + val workflow = Workflow.stateless { + renderCount++ + runningWorker(Worker.from { trigger.await() }) { + action("") { + testScope.cancel() } } - val testScope = TestScope(dispatcher) - renderWorkflowIn( - workflow = workflow, - scope = testScope, - props = MutableStateFlow(Unit), - runtimeConfig = runtimeConfig, - workflowTracer = workflowTracer, - ) {} - advanceIfStandard(dispatcher) - assertNull(cancellationException) - assertTrue(testScope.isActive) - - testScope.cancel(CancellationException("fail!", ExpectedException())) - advanceIfStandard(dispatcher) - assertTrue(cancellationException is CancellationException) - assertTrue(cancellationException!!.cause is ExpectedException) } + renderWorkflowIn( + workflow = workflow, + scope = testScope, + props = MutableStateFlow(Unit), + runtimeConfig = runtimeConfig, + workflowTracer = testTracer, + ) {} + advanceIfStandard(dispatcherUsed) + + assertTrue(testScope.isActive) + assertTrue(renderCount == 1) + + trigger.complete(Unit) + + advanceIfStandard(dispatcherUsed) + + assertFalse(testScope.isActive) + assertEquals( + 1, + renderCount, + "Should not render after CoroutineScope is canceled." + ) + } + + @Test fun failing_scope_cancels_runtime() = + runTest(dispatcherUsed) { + var cancellationException: Throwable? = null + val workflow = Workflow.stateless { + runningSideEffect(key = "failing") { + suspendCancellableCoroutine { continuation -> + continuation.invokeOnCancellation { cause -> cancellationException = cause } + } + } + } + val testScope = TestScope(dispatcherUsed) + renderWorkflowIn( + workflow = workflow, + scope = testScope, + props = MutableStateFlow(Unit), + runtimeConfig = runtimeConfig, + workflowTracer = testTracer, + ) {} + advanceIfStandard(dispatcherUsed) + assertNull(cancellationException) + assertTrue(testScope.isActive) + + testScope.cancel(CancellationException("fail!", ExpectedException())) + advanceIfStandard(dispatcherUsed) + assertTrue(cancellationException is CancellationException) + assertTrue(cancellationException!!.cause is ExpectedException) + } + + @Test fun error_from_renderings_collector_does_not_fail_parent_scope() = + runTest(dispatcherUsed) { + val workflow = Workflow.stateless {} + val testScope = TestScope(dispatcherUsed) + val renderings = renderWorkflowIn( + workflow = workflow, + scope = testScope, + props = MutableStateFlow(Unit), + runtimeConfig = runtimeConfig, + workflowTracer = testTracer, + ) {} + + // Collect in separate scope so we actually test that the parent scope is failed when it's + // different from the collecting scope. + val collectScope = TestScope(dispatcherUsed) + collectScope.launch { + renderings.collect { throw ExpectedException() } + } + advanceIfStandard(dispatcherUsed) + assertTrue(testScope.isActive) + assertFalse(collectScope.isActive) + } + + @Test fun exception_from_onOutput_fails_parent_scope() = + runTest(dispatcherUsed) { + val trigger = CompletableDeferred() + // Emits a Unit when trigger is completed. + val workflow = Workflow.stateless { + runningWorker(Worker.from { trigger.await() }) { action("") { setOutput(Unit) } } + } + val testScope = TestScope(dispatcherUsed) + renderWorkflowIn( + workflow = workflow, + scope = testScope, + props = MutableStateFlow(Unit), + runtimeConfig = runtimeConfig, + workflowTracer = testTracer, + ) { + throw ExpectedException() + } + advanceIfStandard(dispatcherUsed) + assertTrue(testScope.isActive) + + trigger.complete(Unit) + advanceIfStandard(dispatcherUsed) + assertFalse(testScope.isActive) } - } - @Test fun error_from_renderings_collector_does_not_fail_parent_scope() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { (runtimeConfig: RuntimeConfig, workflowTracer: WorkflowTracer?, dispatcher: TestDispatcher) -> - runTest(dispatcher) { - val workflow = Workflow.stateless {} - val testScope = TestScope(dispatcher) - val renderings = renderWorkflowIn( + // https://github.com/square/workflow-kotlin/issues/224 + @Test fun exceptions_from_Snapshots_do_not_fail_runtime() = + runTest(dispatcherUsed) { + val workflow = Workflow.stateful( + snapshot = { + Snapshot.of { + throw ExpectedException() + } + }, + initialState = { _, _ -> }, + render = { _, _ -> } + ) + val props = MutableStateFlow(0) + val uncaughtExceptions = mutableListOf() + val exceptionHandler = CoroutineExceptionHandler { _, throwable -> + uncaughtExceptions += throwable + } + val mutex = Mutex(locked = true) + backgroundScope.launch(exceptionHandler) { + val snapshot = renderWorkflowIn( workflow = workflow, - scope = testScope, - props = MutableStateFlow(Unit), + scope = this, + props = props, runtimeConfig = runtimeConfig, - workflowTracer = workflowTracer, + workflowTracer = testTracer, ) {} + .value + .snapshot - // Collect in separate scope so we actually test that the parent scope is failed when it's - // different from the collecting scope. - val collectScope = TestScope(dispatcher) - collectScope.launch { - renderings.collect { throw ExpectedException() } - } - advanceIfStandard(dispatcher) - assertTrue(testScope.isActive) - assertFalse(collectScope.isActive) + assertFailsWith { snapshot.toByteString() } + assertTrue(uncaughtExceptions.isEmpty()) + + props.value += 1 + assertFailsWith { snapshot.toByteString() } + mutex.unlock() } + // wait for snapshotting. + mutex.lock() } - } - @Test fun exception_from_onOutput_fails_parent_scope() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { (runtimeConfig: RuntimeConfig, workflowTracer: WorkflowTracer?, dispatcher: TestDispatcher) -> - runTest(dispatcher) { - val trigger = CompletableDeferred() - // Emits a Unit when trigger is completed. - val workflow = Workflow.stateless { - runningWorker(Worker.from { trigger.await() }) { action("") { setOutput(Unit) } } - } - val testScope = TestScope(dispatcher) - renderWorkflowIn( - workflow = workflow, - scope = testScope, - props = MutableStateFlow(Unit), - runtimeConfig = runtimeConfig, - workflowTracer = workflowTracer, - ) { + // https://github.com/square/workflow-kotlin/issues/224 + @Test fun exceptions_from_renderings_equals_methods_do_not_fail_runtime() = + runTest(dispatcherUsed) { + @Suppress("EqualsOrHashCode", "unused") + class FailRendering(val value: Int) { + override fun equals(other: Any?): Boolean { throw ExpectedException() } - advanceIfStandard(dispatcher) - assertTrue(testScope.isActive) - - trigger.complete(Unit) - advanceIfStandard(dispatcher) - assertFalse(testScope.isActive) } - } - } - // https://github.com/square/workflow-kotlin/issues/224 - @Test fun exceptions_from_Snapshots_do_not_fail_runtime() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { (runtimeConfig: RuntimeConfig, workflowTracer: WorkflowTracer?, dispatcher: TestDispatcher) -> - runTest(dispatcher) { - val workflow = Workflow.stateful( - snapshot = { - Snapshot.of { - throw ExpectedException() - } - }, - initialState = { _, _ -> }, - render = { _, _ -> } - ) - val props = MutableStateFlow(0) - val uncaughtExceptions = mutableListOf() - val exceptionHandler = CoroutineExceptionHandler { _, throwable -> - uncaughtExceptions += throwable - } - val mutex = Mutex(locked = true) - backgroundScope.launch(exceptionHandler) { - val snapshot = renderWorkflowIn( - workflow = workflow, - scope = this, - props = props, - runtimeConfig = runtimeConfig, - workflowTracer = workflowTracer, - ) {} - .value - .snapshot - - assertFailsWith { snapshot.toByteString() } - assertTrue(uncaughtExceptions.isEmpty()) - - props.value += 1 - assertFailsWith { snapshot.toByteString() } - mutex.unlock() - } - // wait for snapshotting. - mutex.lock() + val workflow = Workflow.stateless { props -> + FailRendering(props) } - } - } + val props = MutableStateFlow(0) + val uncaughtExceptions = mutableListOf() + val exceptionHandler = CoroutineExceptionHandler { _, throwable -> + uncaughtExceptions += throwable + } + val mutex = Mutex(locked = true) + backgroundScope.launch(exceptionHandler) { + val ras = renderWorkflowIn( + workflow = workflow, + scope = this, + props = props, + runtimeConfig = runtimeConfig, + workflowTracer = testTracer, + ) {} + val renderings = ras.map { it.rendering } - // https://github.com/square/workflow-kotlin/issues/224 - @Test fun exceptions_from_renderings_equals_methods_do_not_fail_runtime() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { (runtimeConfig: RuntimeConfig, workflowTracer: WorkflowTracer?, dispatcher: TestDispatcher) -> - runTest(dispatcher) { - @Suppress("EqualsOrHashCode", "unused") - class FailRendering(val value: Int) { - override fun equals(other: Any?): Boolean { - throw ExpectedException() + @Suppress("UnusedEquals") + assertFailsWith { + renderings.collect { + it.equals(Unit) } } + assertTrue(uncaughtExceptions.isEmpty()) - val workflow = Workflow.stateless { props -> - FailRendering(props) - } - val props = MutableStateFlow(0) - val uncaughtExceptions = mutableListOf() - val exceptionHandler = CoroutineExceptionHandler { _, throwable -> - uncaughtExceptions += throwable - } - val mutex = Mutex(locked = true) - backgroundScope.launch(exceptionHandler) { - val ras = renderWorkflowIn( - workflow = workflow, - scope = this, - props = props, - runtimeConfig = runtimeConfig, - workflowTracer = workflowTracer, - ) {} - val renderings = ras.map { it.rendering } - - @Suppress("UnusedEquals") - assertFailsWith { - renderings.collect { - it.equals(Unit) - } - } - assertTrue(uncaughtExceptions.isEmpty()) - - // Trigger another render pass. - props.value += 1 - advanceIfStandard(dispatcher) - mutex.unlock() - } - mutex.lock() + // Trigger another render pass. + props.value += 1 + advanceIfStandard(dispatcherUsed) + mutex.unlock() } + mutex.lock() } - } // https://github.com/square/workflow-kotlin/issues/224 - @Test fun exceptions_from_renderings_hashCode_methods_do_not_fail_runtime() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - ) { (runtimeConfig: RuntimeConfig, workflowTracer: WorkflowTracer?, dispatcher: TestDispatcher) -> - runTest(dispatcher) { - @Suppress("EqualsOrHashCode") - data class FailRendering(val value: Int) { - override fun hashCode(): Int { - throw ExpectedException() - } + @Test fun exceptions_from_renderings_hashCode_methods_do_not_fail_runtime() = + runTest(dispatcherUsed) { + @Suppress("EqualsOrHashCode") + data class FailRendering(val value: Int) { + override fun hashCode(): Int { + throw ExpectedException() } + } - val workflow = Workflow.stateless { props -> - FailRendering(props) - } - val props = MutableStateFlow(0) - val uncaughtExceptions = mutableListOf() - val exceptionHandler = CoroutineExceptionHandler { _, throwable -> - uncaughtExceptions += throwable - } - val mutex = Mutex(locked = true) - backgroundScope.launch(exceptionHandler) { - val ras = renderWorkflowIn( - workflow = workflow, - scope = this, - props = props, - runtimeConfig = runtimeConfig, - workflowTracer = workflowTracer, - ) {} - val renderings = ras.map { it.rendering } - - assertFailsWith { - renderings.collect { - it.hashCode() - } - } - assertTrue(uncaughtExceptions.isEmpty()) + val workflow = Workflow.stateless { props -> + FailRendering(props) + } + val props = MutableStateFlow(0) + val uncaughtExceptions = mutableListOf() + val exceptionHandler = CoroutineExceptionHandler { _, throwable -> + uncaughtExceptions += throwable + } + val mutex = Mutex(locked = true) + backgroundScope.launch(exceptionHandler) { + val ras = renderWorkflowIn( + workflow = workflow, + scope = this, + props = props, + runtimeConfig = runtimeConfig, + workflowTracer = testTracer, + ) {} + val renderings = ras.map { it.rendering } - // Trigger another render pass. - props.value += 1 - advanceIfStandard(dispatcher) - mutex.unlock() + assertFailsWith { + renderings.collect { + it.hashCode() + } } - mutex.lock() + assertTrue(uncaughtExceptions.isEmpty()) + + // Trigger another render pass. + props.value += 1 + advanceIfStandard(dispatcherUsed) + mutex.unlock() } + mutex.lock() } - } @Test fun for_render_on_state_change_only_we_do_not_render_if_state_not_changed() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions.filter { - it.first.contains(RENDER_ONLY_WHEN_STATE_CHANGES) - }, - before = ::setup, - ) { (runtimeConfig: RuntimeConfig, workflowTracer: WorkflowTracer?, dispatcher: TestDispatcher) -> - runTest(dispatcher) { + if (runtimeConfig.contains(RENDER_ONLY_WHEN_STATE_CHANGES)) { + runTest(dispatcherUsed) { check(runtimeConfig.contains(RENDER_ONLY_WHEN_STATE_CHANGES)) lateinit var sink: Sink @@ -1283,7 +1093,7 @@ class RenderWorkflowInTest { scope = backgroundScope, props = props, runtimeConfig = runtimeConfig, - workflowTracer = workflowTracer, + workflowTracer = testTracer, ) {} val emitted = mutableListOf>() @@ -1292,7 +1102,7 @@ class RenderWorkflowInTest { } sink.send("unchanging state") - advanceIfStandard(dispatcher) + advanceIfStandard(dispatcherUsed) collectionJob.cancel() assertEquals(1, emitted.size) @@ -1301,22 +1111,17 @@ class RenderWorkflowInTest { } @Test fun for_render_on_state_change_only_we_report_skipped_in_interceptor() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions.filter { - it.first.contains(RENDER_ONLY_WHEN_STATE_CHANGES) - }, - before = ::setup, - ) { (runtimeConfig: RuntimeConfig, workflowTracer: WorkflowTracer?, dispatcher: TestDispatcher) -> - runTest(dispatcher) { + if (runtimeConfig.contains(RENDER_ONLY_WHEN_STATE_CHANGES)) { + runTest(dispatcherUsed) { check(runtimeConfig.contains(RENDER_ONLY_WHEN_STATE_CHANGES)) lateinit var sink: Sink val interceptedRenderings = mutableListOf() var skippedRenderings = 0 val testInterceptor = object : WorkflowInterceptor { - override fun onRuntimeLoopTick(outcome: RuntimeLoopOutcome) { - if (outcome is RenderPassesComplete<*>) { - interceptedRenderings.add(outcome.renderingAndSnapshot.rendering) - } else if (outcome is RenderPassSkipped) { + override fun onRuntimeUpdate(update: RuntimeUpdate) { + if (update is RenderingProduced<*>) { + interceptedRenderings.add(update.renderingAndSnapshot.rendering) + } else if (update is RenderPassSkipped) { skippedRenderings++ } } @@ -1336,7 +1141,7 @@ class RenderWorkflowInTest { props = props, interceptors = listOf(testInterceptor), runtimeConfig = runtimeConfig, - workflowTracer = workflowTracer, + workflowTracer = testTracer, ) {} val emitted = mutableListOf>() @@ -1345,7 +1150,7 @@ class RenderWorkflowInTest { } sink.send("unchanging state") - advanceIfStandard(dispatcher) + advanceIfStandard(dispatcherUsed) collectionJob.cancel() assertEquals(1, emitted.size) @@ -1356,13 +1161,8 @@ class RenderWorkflowInTest { } @Test fun for_render_on_state_change_only_we_render_if_state_changed() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions.filter { - it.first.contains(RENDER_ONLY_WHEN_STATE_CHANGES) - }, - before = ::setup, - ) { (runtimeConfig: RuntimeConfig, workflowTracer: WorkflowTracer?, dispatcher: TestDispatcher) -> - runTest(dispatcher) { + if (runtimeConfig.contains(RENDER_ONLY_WHEN_STATE_CHANGES)) { + runTest(dispatcherUsed) { check(runtimeConfig.contains(RENDER_ONLY_WHEN_STATE_CHANGES)) lateinit var sink: Sink @@ -1379,7 +1179,7 @@ class RenderWorkflowInTest { scope = backgroundScope, props = props, runtimeConfig = runtimeConfig, - workflowTracer = workflowTracer, + workflowTracer = testTracer, ) {} val emitted = mutableListOf>() @@ -1387,9 +1187,9 @@ class RenderWorkflowInTest { renderings.collect { emitted += it } } - advanceIfStandard(dispatcher) + advanceIfStandard(dispatcherUsed) sink.send("changing state") - advanceIfStandard(dispatcher) + advanceIfStandard(dispatcherUsed) assertEquals(2, emitted.size) collectionJob.cancel() @@ -1399,13 +1199,8 @@ class RenderWorkflowInTest { @Test fun `for_partial_tree_rendering_we_do_not_render_nodes_if_state_not_changed_even_in_render_pass`() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions.filter { - it.first.contains(PARTIAL_TREE_RENDERING) - }, - before = ::setup, - ) { (runtimeConfig: RuntimeConfig, workflowTracer: WorkflowTracer?, dispatcher: TestDispatcher) -> - runTest(dispatcher) { + if (runtimeConfig.contains(PARTIAL_TREE_RENDERING)) { + runTest(dispatcherUsed) { check(runtimeConfig.contains(PARTIAL_TREE_RENDERING)) val trigger = MutableSharedFlow() @@ -1455,12 +1250,12 @@ class RenderWorkflowInTest { scope = backgroundScope, props = props, runtimeConfig = runtimeConfig, - workflowTracer = workflowTracer, + workflowTracer = testTracer, ) {} - advanceIfStandard(dispatcher) + advanceIfStandard(dispatcherUsed) trigger.emit("state 1") // same value as the child starts with. - advanceIfStandard(dispatcher) + advanceIfStandard(dispatcherUsed) assertEquals(2, parentRenderCount) assertEquals(1, childRenderCount) @@ -1469,13 +1264,8 @@ class RenderWorkflowInTest { } @Test fun for_partial_tree_rendering_we_render_nodes_if_state_changed() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions.filter { - it.first.contains(PARTIAL_TREE_RENDERING) - }, - before = ::setup, - ) { (runtimeConfig: RuntimeConfig, workflowTracer: WorkflowTracer?, dispatcher: TestDispatcher) -> - runTest(dispatcher) { + if (runtimeConfig.contains(PARTIAL_TREE_RENDERING)) { + runTest(dispatcherUsed) { check(runtimeConfig.contains(PARTIAL_TREE_RENDERING)) val trigger = MutableSharedFlow() @@ -1525,14 +1315,21 @@ class RenderWorkflowInTest { scope = backgroundScope, props = props, runtimeConfig = runtimeConfig, - workflowTracer = workflowTracer, + workflowTracer = testTracer, ) {} - advanceIfStandard(dispatcher) + advanceIfStandard(dispatcherUsed) trigger.emit("state 1") // different value than the child starts with. - advanceIfStandard(dispatcher) + advanceIfStandard(dispatcherUsed) - assertEquals(3, parentRenderCount) + if (runtimeConfig.contains(DRAIN_EXCLUSIVE_ACTIONS) && + dispatcherUsed == myStandardTestDispatcher + ) { + // With the unconfined dispatcher, the other actions don't get queued up in time to drain. + assertEquals(2, parentRenderCount) + } else { + assertEquals(3, parentRenderCount) + } // Parent needs to be rendered 3x, but child only 2x as the 3rd time its the same. assertEquals(2, childRenderCount) } @@ -1541,15 +1338,11 @@ class RenderWorkflowInTest { @Test fun for_render_on_change_only_and_conflate_we_drain_action_but_do_not_render_no_state_changed() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions.filter { - it.first.contains(RENDER_ONLY_WHEN_STATE_CHANGES) && it.first.contains( - CONFLATE_STALE_RENDERINGS - ) - }, - before = ::setup, - ) { (runtimeConfig: RuntimeConfig, workflowTracer: WorkflowTracer?, dispatcher: TestDispatcher) -> - runTest(dispatcher) { + if (runtimeConfig.contains(RENDER_ONLY_WHEN_STATE_CHANGES) && runtimeConfig.contains( + CONFLATE_STALE_RENDERINGS + ) + ) { + runTest(dispatcherUsed) { check(runtimeConfig.contains(CONFLATE_STALE_RENDERINGS)) check(runtimeConfig.contains(RENDER_ONLY_WHEN_STATE_CHANGES)) @@ -1600,16 +1393,16 @@ class RenderWorkflowInTest { scope = backgroundScope, props = props, runtimeConfig = runtimeConfig, - workflowTracer = workflowTracer, + workflowTracer = testTracer, ) { outputSet.add(it) } - advanceIfStandard(dispatcher) + advanceIfStandard(dispatcherUsed) launch { trigger.emit("changed state") } - advanceIfStandard(dispatcher) + advanceIfStandard(dispatcherUsed) // 2 renderings (initial and then the update.) Not *3* renderings. assertEquals(2, renderCount) @@ -1628,14 +1421,8 @@ class RenderWorkflowInTest { */ @Test fun for_conflate_we_conflate_stacked_actions_into_one_rendering() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions - .filter { - it.first.contains(CONFLATE_STALE_RENDERINGS) - }, - before = ::setup, - ) { (runtimeConfig: RuntimeConfig, workflowTracer: WorkflowTracer?, dispatcher: TestDispatcher) -> - runTest(dispatcher) { + if (runtimeConfig.contains(CONFLATE_STALE_RENDERINGS)) { + runTest(dispatcherUsed) { check(runtimeConfig.contains(CONFLATE_STALE_RENDERINGS)) var childHandlerActionExecuted = false @@ -1683,13 +1470,13 @@ class RenderWorkflowInTest { scope = backgroundScope, props = props, runtimeConfig = runtimeConfig, - workflowTracer = workflowTracer, + workflowTracer = testTracer, ) { // Yield in output so that we ensure that we let the collector of the renderings // collect each of them before processing the next action. yield() } - advanceIfStandard(dispatcher) + advanceIfStandard(dispatcherUsed) val collectionJob = launch { // Collect this unconfined so we can get all the renderings faster than actions can @@ -1698,11 +1485,11 @@ class RenderWorkflowInTest { emitted += it.rendering } } - advanceIfStandard(dispatcher) + advanceIfStandard(dispatcherUsed) launch { trigger.emit("changed state") } - advanceIfStandard(dispatcher) + advanceIfStandard(dispatcherUsed) collectionJob.cancel() @@ -1716,14 +1503,8 @@ class RenderWorkflowInTest { @Test fun for_conflate_we_do_not_conflate_stacked_actions_into_one_rendering_if_output() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions - .filter { - it.first.contains(CONFLATE_STALE_RENDERINGS) - }, - before = ::setup, - ) { (runtimeConfig: RuntimeConfig, workflowTracer: WorkflowTracer?, dispatcher: TestDispatcher) -> - runTest(dispatcher) { + if (runtimeConfig.contains(CONFLATE_STALE_RENDERINGS)) { + runTest(dispatcherUsed) { check(runtimeConfig.contains(CONFLATE_STALE_RENDERINGS)) var childHandlerActionExecuted = false @@ -1772,7 +1553,7 @@ class RenderWorkflowInTest { scope = backgroundScope, props = props, runtimeConfig = runtimeConfig, - workflowTracer = workflowTracer, + workflowTracer = testTracer, ) { // Yield in output so that we ensure that we let the collector of the renderings // collect each of them before processing the next action. @@ -1786,12 +1567,12 @@ class RenderWorkflowInTest { emitted += it.rendering } } - advanceIfStandard(dispatcher) + advanceIfStandard(dispatcherUsed) launch { trigger.emit("changed state") } - advanceIfStandard(dispatcher) + advanceIfStandard(dispatcherUsed) collectionJob.cancel() @@ -1805,15 +1586,10 @@ class RenderWorkflowInTest { @Test fun for_conflate_and_render_only_when_state_changed_we_do_not_conflate_stacked_actions_into_one_rendering_if_previous_rendering_changed() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions - .filter { - it.first.contains(CONFLATE_STALE_RENDERINGS) && - it.first.contains(RENDER_ONLY_WHEN_STATE_CHANGES) - }, - before = ::setup, - ) { (runtimeConfig: RuntimeConfig, workflowTracer: WorkflowTracer?, dispatcher: TestDispatcher) -> - runTest(dispatcher) { + if (runtimeConfig.contains(CONFLATE_STALE_RENDERINGS) && + runtimeConfig.contains(RENDER_ONLY_WHEN_STATE_CHANGES) + ) { + runTest(dispatcherUsed) { check(runtimeConfig.contains(CONFLATE_STALE_RENDERINGS)) check(runtimeConfig.contains(RENDER_ONLY_WHEN_STATE_CHANGES)) @@ -1860,7 +1636,7 @@ class RenderWorkflowInTest { scope = backgroundScope, props = props, runtimeConfig = runtimeConfig, - workflowTracer = workflowTracer, + workflowTracer = testTracer, ) { } val collectionJob = launch { @@ -1870,12 +1646,12 @@ class RenderWorkflowInTest { emitted += it.rendering } } - advanceIfStandard(dispatcher) + advanceIfStandard(dispatcherUsed) launch { trigger.emit("changed state") } - advanceIfStandard(dispatcher) + advanceIfStandard(dispatcherUsed) collectionJob.cancel() @@ -1889,15 +1665,10 @@ class RenderWorkflowInTest { @Test fun for_conflate_and_render_only_when_state_changed_we_do_not_render_again_if_only_previous_rendering_changed() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions - .filter { - it.first.contains(CONFLATE_STALE_RENDERINGS) && - it.first.contains(RENDER_ONLY_WHEN_STATE_CHANGES) - }, - before = ::setup, - ) { (runtimeConfig: RuntimeConfig, workflowTracer: WorkflowTracer?, dispatcher: TestDispatcher) -> - runTest(dispatcher) { + if (runtimeConfig.contains(CONFLATE_STALE_RENDERINGS) && + runtimeConfig.contains(RENDER_ONLY_WHEN_STATE_CHANGES) + ) { + runTest(dispatcherUsed) { check(runtimeConfig.contains(CONFLATE_STALE_RENDERINGS)) check(runtimeConfig.contains(RENDER_ONLY_WHEN_STATE_CHANGES)) @@ -1947,7 +1718,7 @@ class RenderWorkflowInTest { scope = backgroundScope, props = props, runtimeConfig = runtimeConfig, - workflowTracer = workflowTracer, + workflowTracer = testTracer, ) { } val collectionJob = launch { @@ -1957,12 +1728,12 @@ class RenderWorkflowInTest { emitted += it.rendering } } - advanceIfStandard(dispatcher) + advanceIfStandard(dispatcherUsed) launch { trigger.emit("changed state") } - advanceIfStandard(dispatcher) + advanceIfStandard(dispatcherUsed) collectionJob.cancel() @@ -1976,32 +1747,300 @@ class RenderWorkflowInTest { } } - private class ExpectedException : RuntimeException() + @Test + fun for_drain_exclusive_we_handle_multiple_actions_in_one_render_or_not() = runTest( + dispatcherUsed + ) { + + var childActionAppliedCount = 0 + var parentRenderCount = 0 + val trigger = MutableSharedFlow() + + val childWorkflow = Workflow.stateful( + initialState = "unchanged state", + render = { renderState -> + runningWorker( + trigger.asWorker() + ) { + action("") { + state = it + childActionAppliedCount++ + } + } + renderState + } + ) + val workflow = Workflow.stateful( + initialState = "unchanging state", + render = { renderState -> + renderChild(childWorkflow, key = "key1") { _ -> + WorkflowAction.noAction() + } + renderChild(childWorkflow, key = "key2") { _ -> + WorkflowAction.noAction() + } + parentRenderCount++ + renderState + } + ) + val props = MutableStateFlow(Unit) + renderWorkflowIn( + workflow = workflow, + scope = backgroundScope, + props = props, + runtimeConfig = runtimeConfig, + workflowTracer = testTracer, + ) { } + advanceIfStandard(dispatcherUsed) + + launch { + trigger.emit("changed state") + } + advanceIfStandard(dispatcherUsed) + + // 2 child actions processed. + assertEquals(2, childActionAppliedCount, "Expecting 2 child actions to be applied.") + if (runtimeConfig.contains(DRAIN_EXCLUSIVE_ACTIONS)) { + // and 2 parent renders - 1 initial (synchronous) and then 1 additional. + assertEquals(2, parentRenderCount, "Expecting only 2 total renders.") + } else { + // and 3 parent renders - 1 initial (synchronous) and then 1 additional for each child. + assertEquals(3, parentRenderCount, "Expecting only 3 total renders.") + } + } + + @Test + fun `for_drain_exclusive_and_render_only_when_state_changes_we_handle_multiple_actions_in_one_render_but_do_not_render_if_no_state_change`() { + if (runtimeConfig.contains(DRAIN_EXCLUSIVE_ACTIONS) && + runtimeConfig.contains(RENDER_ONLY_WHEN_STATE_CHANGES) + ) { + runTest(dispatcherUsed) { + check(runtimeConfig.contains(DRAIN_EXCLUSIVE_ACTIONS)) + check(runtimeConfig.contains(RENDER_ONLY_WHEN_STATE_CHANGES)) + + var childActionAppliedCount = 0 + var parentRenderCount = 0 + val trigger = MutableSharedFlow() + val receivedOutputs = mutableListOf() + + val childWorkflow = Workflow.stateful( + initialState = "unchanged state", + render = { renderState -> + runningWorker( + trigger.asWorker() + ) { + action("") { + // no state change! + childActionAppliedCount++ + setOutput(it) + } + } + renderState + } + ) + val workflow = Workflow.stateful( + initialState = "unchanging state", + render = { renderState -> + renderChild(childWorkflow, key = "key1") { _ -> + WorkflowAction.noAction() + } + renderChild(childWorkflow, key = "key2") { childOutput -> + action(name = "Child2Handler") { + // Second one sets output to test that we still send the output! + setOutput(childOutput) + } + } + parentRenderCount++ + renderState + } + ) + val props = MutableStateFlow(Unit) + renderWorkflowIn( + workflow = workflow, + scope = backgroundScope, + props = props, + runtimeConfig = runtimeConfig, + workflowTracer = testTracer, + ) { + receivedOutputs.add(it) + } + advanceIfStandard(dispatcherUsed) + + launch { + trigger.emit("changed state") + } + advanceIfStandard(dispatcherUsed) + + // 2 child actions processed and 1 parent render - only the initial one. + assertEquals(2, childActionAppliedCount, "Expected each child action applied.") + assertEquals(1, parentRenderCount, "Expected parent only rendered once.") + assertEquals(1, receivedOutputs.size, "Expected one output.") + assertEquals("changed state", receivedOutputs[0]) + } + } + } + + @Test + fun `for_drain_exclusive_and_render_only_when_state_changes_we_handle_multiple_actions_in_one_render_but_we_do_pass_rendering_if_state_changed_earlier`() { + if (runtimeConfig.contains(DRAIN_EXCLUSIVE_ACTIONS) && + runtimeConfig.contains(RENDER_ONLY_WHEN_STATE_CHANGES) + ) { + runTest(dispatcherUsed) { + check(runtimeConfig.contains(DRAIN_EXCLUSIVE_ACTIONS)) + check(runtimeConfig.contains(RENDER_ONLY_WHEN_STATE_CHANGES)) + + var childActionAppliedCount = 0 + var parentRenderCount = 0 + val trigger = MutableSharedFlow() + val receivedOutputs = mutableListOf() + val emitted = mutableListOf() + + val childWorkflow = Workflow.stateful( + initialState = "unchanged state", + render = { renderState -> + runningWorker( + trigger.asWorker() + ) { + action("") { + if (childActionAppliedCount == 0) { + // change state on the first one. + state = "$it+update" + } else { + // no state change! + } + childActionAppliedCount++ + setOutput(it) + } + } + renderState + } + ) + val workflow = Workflow.stateful( + initialState = "unchanging state", + render = { renderState -> + renderChild(childWorkflow, key = "key1") { _ -> + WorkflowAction.noAction() + } + renderChild(childWorkflow, key = "key2") { childOutput -> + action(name = "Child2Handler") { + // Second one sets output to test that we still send the output! + setOutput(childOutput) + } + } + parentRenderCount++ + renderState + } + ) + val props = MutableStateFlow(Unit) + val renderings = renderWorkflowIn( + workflow = workflow, + scope = backgroundScope, + props = props, + runtimeConfig = runtimeConfig, + workflowTracer = testTracer, + ) { + receivedOutputs.add(it) + } + advanceIfStandard(dispatcherUsed) + + val collectionJob = launch { + // Collect this unconfined so we can get all the renderings faster than actions can + // be processed. + renderings.collect { + emitted += it.rendering + } + } + advanceIfStandard(dispatcherUsed) + + launch { + trigger.emit("changed state") + } + advanceIfStandard(dispatcherUsed) + + collectionJob.cancel() - private fun cartesianProduct( - set1: Sequence, - set2: Sequence - ): Sequence> { - return set1.flatMap { set1Item -> set2.map { set2Item -> set1Item to set2Item } } + // 2 renderings received always as the state on child changes! + assertEquals(2, emitted.size) + // 2 child actions processed and 1 (or 2) parent renders. + assertEquals(2, childActionAppliedCount, "Expected each child action applied.") + if (runtimeConfig.contains(PARTIAL_TREE_RENDERING)) { + assertEquals(1, parentRenderCount, "Expected parent only rendered once.") + } else { + assertEquals(2, parentRenderCount, "Expected parent rendered twice.") + } + assertEquals(1, receivedOutputs.size, "Expected one output.") + assertEquals("changed state", receivedOutputs[0]) + } + } } - private fun cartesianProduct( - set1: Sequence, - set2: Sequence, - set3: Sequence - ): Sequence> { - return set1.flatMap { set1Item -> set2.map { set2Item -> set1Item to set2Item } } - .flatMap { (set1Item, set2Item) -> - set3.map { set3Item -> - Triple( - set1Item, - set2Item, - set3Item - ) + @Test + fun for_drain_exclusive_we_do_not_handle_multiple_actions_in_one_render_if_not_exclusive() { + if (runtimeConfig.contains(DRAIN_EXCLUSIVE_ACTIONS)) { + runTest(dispatcherUsed) { + check(runtimeConfig.contains(DRAIN_EXCLUSIVE_ACTIONS)) + + var childActionAppliedCount = 0 + var parentRenderCount = 0 + val trigger = MutableSharedFlow() + + val childWorkflow = Workflow.stateful( + initialState = "unchanged state", + render = { renderState -> + runningWorker( + trigger.asWorker() + ) { + action("") { + state = it + childActionAppliedCount++ + // set the output to dirty the parent node. + setOutput(it) + } + } + renderState + } + ) + val workflow = Workflow.stateful( + initialState = "unchanging state", + render = { renderState -> + renderChild(childWorkflow, key = "key1") { childOutput -> + action("childHandler1") { + state = childOutput + } + } + renderChild(childWorkflow, key = "key2") { childOutput -> + action("childHandler2") { + state = childOutput + } + } + parentRenderCount++ + renderState + } + ) + val props = MutableStateFlow(Unit) + renderWorkflowIn( + workflow = workflow, + scope = backgroundScope, + props = props, + runtimeConfig = runtimeConfig, + workflowTracer = testTracer, + ) { } + advanceIfStandard(dispatcherUsed) + + launch { + trigger.emit("changed state") } + advanceIfStandard(dispatcherUsed) + + // 2 child actions processed and 3 parent renders + assertEquals(2, childActionAppliedCount, "Expecting 2 child actions applied") + assertEquals(3, parentRenderCount, "Expecting 3 parent renders") } + } } + private class ExpectedException : RuntimeException() + companion object { internal val EXPECTED_TRACE: String = """ StartingCreateWorkerWorkflow diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/ParameterizedTestRunner.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/ParameterizedTestRunner.kt deleted file mode 100644 index 794107b35..000000000 --- a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/ParameterizedTestRunner.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.squareup.workflow1.internal - -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertNotSame -import kotlin.test.assertNull -import kotlin.test.assertTrue - -/** - * Simple parameterized test as we are in KMP commonTest code and don't have junit - * libraries like jupiter. - * - * We do our best to tell you what the parameter was when the failure occurred by wrapping - * assertions from kotlin.test and injecting our own message. - */ -class ParameterizedTestRunner

{ - - var currentParam: P? = null - - fun runParametrizedTest( - paramSource: Sequence

, - before: () -> Unit = {}, - after: () -> Unit = {}, - test: ParameterizedTestRunner

.(param: P) -> Unit - ) { - paramSource.forEach { - before() - currentParam = it - test(it) - after() - } - } - - fun assertEquals(expected: T, actual: T) { - assertEquals(expected, actual, message = "Using: ${currentParam?.toString()}") - } - - fun assertEquals(expected: T, actual: T, originalMessage: String) { - assertEquals(expected, actual, message = "$originalMessage; Using: ${currentParam?.toString()}") - } - - fun assertTrue(statement: Boolean) { - assertTrue(statement, message = "Using: ${currentParam?.toString()}") - } - - fun assertFalse(statement: Boolean) { - assertFalse(statement, message = "Using: ${currentParam?.toString()}") - } - - inline fun assertFailsWith(block: () -> Unit) { - assertFailsWith(message = "Using: ${currentParam?.toString()}", block) - } - - fun assertNotSame(illegal: T, actual: T) { - assertNotSame(illegal, actual, message = "Using: ${currentParam?.toString()}") - } - - fun assertNotNull(actual: T?) { - assertNotNull(actual, message = "Using: ${currentParam?.toString()}") - } - - fun assertNull(actual: Any?) { - assertNull(actual, message = "Using: ${currentParam?.toString()}") - } -} diff --git a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/WorkflowRunnerTest.kt b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/WorkflowRunnerTest.kt index 18e657ce4..09cfaa9a4 100644 --- a/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/WorkflowRunnerTest.kt +++ b/workflow-runtime/src/commonTest/kotlin/com/squareup/workflow1/internal/WorkflowRunnerTest.kt @@ -1,12 +1,12 @@ package com.squareup.workflow1.internal +import app.cash.burst.Burst import com.squareup.workflow1.ActionApplied import com.squareup.workflow1.NoopWorkflowInterceptor import com.squareup.workflow1.RuntimeConfig import com.squareup.workflow1.RuntimeConfigOptions -import com.squareup.workflow1.RuntimeConfigOptions.CONFLATE_STALE_RENDERINGS -import com.squareup.workflow1.RuntimeConfigOptions.PARTIAL_TREE_RENDERING -import com.squareup.workflow1.RuntimeConfigOptions.RENDER_ONLY_WHEN_STATE_CHANGES +import com.squareup.workflow1.RuntimeConfigOptions.Companion.RuntimeOptions +import com.squareup.workflow1.RuntimeConfigOptions.Companion.RuntimeOptions.DEFAULT import com.squareup.workflow1.Worker import com.squareup.workflow1.Workflow import com.squareup.workflow1.WorkflowExperimentalRuntime @@ -24,313 +24,245 @@ import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent +import kotlin.test.AfterTest +import kotlin.test.BeforeTest import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class, WorkflowExperimentalRuntime::class) -internal class WorkflowRunnerTest { +@Burst +internal class WorkflowRunnerTest( + runtime: RuntimeOptions = DEFAULT +) { private lateinit var scope: TestScope + private val runtimeConfig = runtime.runtimeConfig - private val runtimeOptions = arrayOf( - RuntimeConfigOptions.RENDER_PER_ACTION, - setOf(RENDER_ONLY_WHEN_STATE_CHANGES), - setOf(CONFLATE_STALE_RENDERINGS), - setOf(CONFLATE_STALE_RENDERINGS, RENDER_ONLY_WHEN_STATE_CHANGES), - setOf(RENDER_ONLY_WHEN_STATE_CHANGES, PARTIAL_TREE_RENDERING), - setOf(CONFLATE_STALE_RENDERINGS, RENDER_ONLY_WHEN_STATE_CHANGES, PARTIAL_TREE_RENDERING), - ).asSequence() - - private fun setup() { + @BeforeTest + public fun setup() { scope = TestScope() } - private fun tearDown() { + @AfterTest + public fun tearDown() { scope.cancel() } - private val runtimeTestRunner = ParameterizedTestRunner() - @Test fun initial_nextRendering_returns_initial_rendering() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - after = ::tearDown, - ) { runtimeConfig: RuntimeConfig -> - - val workflow = Workflow.stateless { "foo" } - val runner = WorkflowRunner( - workflow, - MutableStateFlow(Unit), - runtimeConfig - ) - val rendering = runner.nextRendering().rendering - assertEquals("foo", rendering) - } + val workflow = Workflow.stateless { "foo" } + val runner = WorkflowRunner( + workflow, + MutableStateFlow(Unit), + runtimeConfig + ) + val rendering = runner.nextRendering().rendering + assertEquals("foo", rendering) } @Test fun initial_nextRendering_uses_initial_props() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - after = ::tearDown, - ) { runtimeConfig: RuntimeConfig -> - - val workflow = Workflow.stateless { it } - val runner = WorkflowRunner( - workflow, - MutableStateFlow("foo"), - runtimeConfig - ) - val rendering = runner.nextRendering().rendering - assertEquals("foo", rendering) - } + val workflow = Workflow.stateless { it } + val runner = WorkflowRunner( + workflow, + MutableStateFlow("foo"), + runtimeConfig + ) + val rendering = runner.nextRendering().rendering + assertEquals("foo", rendering) } - @Test fun initial_processActions_does_not_handle_initial_props() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - after = ::tearDown, - ) { runtimeConfig: RuntimeConfig -> - - val workflow = Workflow.stateless { it } - val props = MutableStateFlow("initial") - val runner = WorkflowRunner( - workflow, - props, - runtimeConfig - ) - runner.nextRendering() - - val outputDeferred = scope.async { runner.processAction() } - - scope.runCurrent() - assertTrue(outputDeferred.isActive) - } + @Test fun initial_waitAndProcessActions_does_not_handle_initial_props() { + val workflow = Workflow.stateless { it } + val props = MutableStateFlow("initial") + val runner = WorkflowRunner( + workflow, + props, + runtimeConfig + ) + runner.nextRendering() + + val outputDeferred = scope.async { runner.waitAndProcessAction() } + + scope.runCurrent() + assertTrue(outputDeferred.isActive) } - @Test fun initial_processActions_handles_props_changed_after_initialization() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - after = ::tearDown, - ) { runtimeConfig: RuntimeConfig -> - - val workflow = Workflow.stateless { it } - val props = MutableStateFlow("initial") - // The dispatcher is paused, so the produceIn coroutine won't start yet. - val runner = WorkflowRunner( - workflow, - props, - runtimeConfig - ) - // The initial value will be read during initialization, so we can change it any time after - // that. - props.value = "changed" - - // Get the runner into the state where it's waiting for a props update. - val initialRendering = runner.nextRendering().rendering - assertEquals("initial", initialRendering) - val output = scope.async { runner.processAction() } - assertTrue(output.isActive) - - // Resume the dispatcher to start the coroutines and process the new props value. - scope.runCurrent() - - assertTrue(output.isCompleted) - @Suppress("UNCHECKED_CAST") - val outputValue = output.getCompleted() as? ActionApplied? - assertNull(outputValue) - val rendering = runner.nextRendering().rendering - assertEquals("changed", rendering) - } + @Test fun initial_waitAndProcessActions_handles_props_changed_after_initialization() { + val workflow = Workflow.stateless { it } + val props = MutableStateFlow("initial") + // The dispatcher is paused, so the produceIn coroutine won't start yet. + val runner = WorkflowRunner( + workflow, + props, + runtimeConfig + ) + // The initial value will be read during initialization, so we can change it any time after + // that. + props.value = "changed" + + // Get the runner into the state where it's waiting for a props update. + val initialRendering = runner.nextRendering().rendering + assertEquals("initial", initialRendering) + val output = scope.async { runner.waitAndProcessAction() } + assertTrue(output.isActive) + + // Resume the dispatcher to start the coroutines and process the new props value. + scope.runCurrent() + + assertTrue(output.isCompleted) + @Suppress("UNCHECKED_CAST") + val outputValue = output.getCompleted() as? ActionApplied? + assertNull(outputValue) + val rendering = runner.nextRendering().rendering + assertEquals("changed", rendering) } - @Test fun processActions_handles_workflow_update() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - after = ::tearDown, - ) { runtimeConfig: RuntimeConfig -> - - val workflow = Workflow.stateful( - initialState = { "initial" }, - render = { _, renderState -> - runningWorker(Worker.from { "work" }) { - action("") { - state = "state: $it" - setOutput("output: $it") - } + @Test fun waitAndProcessActions_handles_workflow_update() { + val workflow = Workflow.stateful( + initialState = { "initial" }, + render = { _, renderState -> + runningWorker(Worker.from { "work" }) { + action("") { + state = "state: $it" + setOutput("output: $it") } - return@stateful renderState } - ) - val runner = - WorkflowRunner(workflow, MutableStateFlow(Unit), runtimeConfig) + return@stateful renderState + } + ) + val runner = + WorkflowRunner(workflow, MutableStateFlow(Unit), runtimeConfig) - val initialRendering = runner.nextRendering().rendering - assertEquals("initial", initialRendering) + val initialRendering = runner.nextRendering().rendering + assertEquals("initial", initialRendering) - val actionResult = runner.runTillNextActionResult() - assertEquals("output: work", actionResult!!.output!!.value) + val actionResult = runner.runTillNextActionResult() + assertEquals("output: work", actionResult!!.output!!.value) - val updatedRendering = runner.nextRendering().rendering - assertEquals("state: work", updatedRendering) - } + val updatedRendering = runner.nextRendering().rendering + assertEquals("state: work", updatedRendering) } - @Test fun processActions_handles_concurrent_props_change_and_workflow_update() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - after = ::tearDown, - ) { runtimeConfig: RuntimeConfig -> - - val workflow = Workflow.stateful( - initialState = { "initial state($it)" }, - render = { renderProps, renderState -> - runningWorker(Worker.from { "work" }) { - action("") { - state = "state: $it" - setOutput("output: $it") - } + @Test fun waitAndProcessActions_handles_concurrent_props_change_and_workflow_update() { + val workflow = Workflow.stateful( + initialState = { "initial state($it)" }, + render = { renderProps, renderState -> + runningWorker(Worker.from { "work" }) { + action("") { + state = "state: $it" + setOutput("output: $it") } - return@stateful "$renderProps|$renderState" } - ) - val props = MutableStateFlow("initial props") - val runner = WorkflowRunner(workflow, props, runtimeConfig) - props.value = "changed props" - val initialRendering = runner.nextRendering().rendering - assertEquals("initial props|initial state(initial props)", initialRendering) - - // The order in which props update and workflow update are processed is deterministic, based - // on the order they appear in the select block in processActions. - val firstActionResult = runner.runTillNextActionResult() - // First update will be props, so no output value. - assertNull(firstActionResult) - val secondRendering = runner.nextRendering().rendering - assertEquals("changed props|initial state(initial props)", secondRendering) - - val secondActionResult = runner.runTillNextActionResult() - assertEquals("output: work", secondActionResult!!.output!!.value) - val thirdRendering = runner.nextRendering().rendering - assertEquals("changed props|state: work", thirdRendering) - } + return@stateful "$renderProps|$renderState" + } + ) + val props = MutableStateFlow("initial props") + val runner = WorkflowRunner(workflow, props, runtimeConfig) + props.value = "changed props" + val initialRendering = runner.nextRendering().rendering + assertEquals("initial props|initial state(initial props)", initialRendering) + + // The order in which props update and workflow update are processed is deterministic, based + // on the order they appear in the select block in processActions. + val firstActionResult = runner.runTillNextActionResult() + // First update will be props, so no output value. + assertNull(firstActionResult) + val secondRendering = runner.nextRendering().rendering + assertEquals("changed props|initial state(initial props)", secondRendering) + + val secondActionResult = runner.runTillNextActionResult() + assertEquals("output: work", secondActionResult!!.output!!.value) + val thirdRendering = runner.nextRendering().rendering + assertEquals("changed props|state: work", thirdRendering) } - @Test fun cancelRuntime_does_not_interrupt_processActions() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - after = ::tearDown, - ) { runtimeConfig: RuntimeConfig -> - val workflow = Workflow.stateless {} - val runner = - WorkflowRunner(workflow, MutableStateFlow(Unit), runtimeConfig) - runner.nextRendering() - val output = scope.async { runner.processAction() } - scope.runCurrent() - assertTrue(output.isActive) - - // processActions is run on the scope passed to the runner, so it shouldn't be affected by this - // call. - runner.cancelRuntime() - - scope.advanceUntilIdle() - assertTrue(output.isActive) - } + @Test fun cancelRuntime_does_not_interrupt_waitAndProcessActions() { + val workflow = Workflow.stateless {} + val runner = + WorkflowRunner(workflow, MutableStateFlow(Unit), runtimeConfig) + runner.nextRendering() + val output = scope.async { runner.waitAndProcessAction() } + scope.runCurrent() + assertTrue(output.isActive) + + // processActions is run on the scope passed to the runner, so it shouldn't be affected by this + // call. + runner.cancelRuntime() + + scope.advanceUntilIdle() + assertTrue(output.isActive) } @Test fun cancelRuntime_cancels_runtime() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - after = ::tearDown, - ) { runtimeConfig: RuntimeConfig -> - - var cancellationException: Throwable? = null - val workflow = Workflow.stateless { - runningSideEffect(key = "test side effect") { - suspendCancellableCoroutine { continuation -> - continuation.invokeOnCancellation { cause -> cancellationException = cause } - } + var cancellationException: Throwable? = null + val workflow = Workflow.stateless { + runningSideEffect(key = "test side effect") { + suspendCancellableCoroutine { continuation -> + continuation.invokeOnCancellation { cause -> cancellationException = cause } } } - val runner = - WorkflowRunner(workflow, MutableStateFlow(Unit), runtimeConfig) - runner.nextRendering() - scope.runCurrent() - assertNull(cancellationException) - - runner.cancelRuntime() - - scope.advanceUntilIdle() - assertNotNull(cancellationException) - val causes = generateSequence(cancellationException) { it.cause } - assertTrue(causes.all { it is CancellationException }) } + val runner = + WorkflowRunner(workflow, MutableStateFlow(Unit), runtimeConfig) + runner.nextRendering() + scope.runCurrent() + assertNull(cancellationException) + + runner.cancelRuntime() + + scope.advanceUntilIdle() + assertNotNull(cancellationException) + val causes = generateSequence(cancellationException) { it.cause } + assertTrue(causes.all { it is CancellationException }) } - @Test fun cancelling_scope_interrupts_processActions() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - after = ::tearDown, - ) { runtimeConfig: RuntimeConfig -> - - val workflow = Workflow.stateless {} - val runner = - WorkflowRunner(workflow, MutableStateFlow(Unit), runtimeConfig) - runner.nextRendering() - val actionResult = scope.async { runner.processAction() } - scope.runCurrent() - assertTrue(actionResult.isActive) - - scope.cancel("foo") - - scope.advanceUntilIdle() - assertTrue(actionResult.isCancelled) - val realCause = actionResult.getCompletionExceptionOrNull() - assertEquals("foo", realCause?.message) - } + @Test fun cancelling_scope_interrupts_waitAndProcessActions() { + val workflow = Workflow.stateless {} + val runner = + WorkflowRunner(workflow, MutableStateFlow(Unit), runtimeConfig) + runner.nextRendering() + val actionResult = scope.async { runner.waitAndProcessAction() } + scope.runCurrent() + assertTrue(actionResult.isActive) + + scope.cancel("foo") + + scope.advanceUntilIdle() + assertTrue(actionResult.isCancelled) + val realCause = actionResult.getCompletionExceptionOrNull() + assertEquals("foo", realCause?.message) } @Test fun cancelling_scope_cancels_runtime() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - before = ::setup, - after = ::tearDown, - ) { runtimeConfig: RuntimeConfig -> - - var cancellationException: Throwable? = null - val workflow = Workflow.stateless { - runningSideEffect(key = "test") { - suspendCancellableCoroutine { continuation -> - continuation.invokeOnCancellation { cause -> cancellationException = cause } - } + var cancellationException: Throwable? = null + val workflow = Workflow.stateless { + runningSideEffect(key = "test") { + suspendCancellableCoroutine { continuation -> + continuation.invokeOnCancellation { cause -> cancellationException = cause } } } - val runner = - WorkflowRunner(workflow, MutableStateFlow(Unit), runtimeConfig) - runner.nextRendering() - val actionResult = scope.async { runner.processAction() } - scope.runCurrent() - assertTrue(actionResult.isActive) - assertNull(cancellationException) - - scope.cancel("foo") - - scope.advanceUntilIdle() - assertTrue(actionResult.isCancelled) - assertNotNull(cancellationException) - assertEquals("foo", cancellationException!!.message) } + val runner = + WorkflowRunner(workflow, MutableStateFlow(Unit), runtimeConfig) + runner.nextRendering() + val actionResult = scope.async { runner.waitAndProcessAction() } + scope.runCurrent() + assertTrue(actionResult.isActive) + assertNull(cancellationException) + + scope.cancel("foo") + + scope.advanceUntilIdle() + assertTrue(actionResult.isCancelled) + assertNotNull(cancellationException) + assertEquals("foo", cancellationException!!.message) } @Suppress("UNCHECKED_CAST") private fun WorkflowRunner<*, T, *>.runTillNextActionResult(): ActionApplied? = scope.run { - val firstOutputDeferred = async { processAction() } + val firstOutputDeferred = async { waitAndProcessAction() } runCurrent() // If it is [ PropsUpdated] or any other ActionProcessingResult, will return as null. firstOutputDeferred.getCompleted() as? ActionApplied diff --git a/workflow-runtime/src/jvmMain/kotlin/com/squareup/workflow1/internal/Throwables.jvm.kt b/workflow-runtime/src/jvmMain/kotlin/com/squareup/workflow1/internal/Throwables.jvm.kt index eec7784ff..f4ddf5e2c 100644 --- a/workflow-runtime/src/jvmMain/kotlin/com/squareup/workflow1/internal/Throwables.jvm.kt +++ b/workflow-runtime/src/jvmMain/kotlin/com/squareup/workflow1/internal/Throwables.jvm.kt @@ -1,6 +1,6 @@ package com.squareup.workflow1.internal -internal actual fun T.withKey(stackTraceKey: Any): T = apply { +actual fun T.withKey(stackTraceKey: Any): T = apply { val realTop = stackTrace[0] val fakeTop = StackTraceElement( // Real class name to ensure that we are still "in project". diff --git a/workflow-testing/api/workflow-testing.api b/workflow-testing/api/workflow-testing.api index fc8db0ba3..73aa951f3 100644 --- a/workflow-testing/api/workflow-testing.api +++ b/workflow-testing/api/workflow-testing.api @@ -11,7 +11,7 @@ public final class com/squareup/workflow1/testing/RenderIdempotencyChecker : com public fun onPropsChanged (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; public fun onRender (Ljava/lang/Object;Ljava/lang/Object;Lcom/squareup/workflow1/BaseRenderContext;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; public fun onRenderAndSnapshot (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/RenderingAndSnapshot; - public fun onRuntimeLoopTick (Lcom/squareup/workflow1/WorkflowInterceptor$RuntimeLoopOutcome;)V + public fun onRuntimeUpdate (Lcom/squareup/workflow1/WorkflowInterceptor$RuntimeUpdate;)V public fun onSessionStarted (Lkotlinx/coroutines/CoroutineScope;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)V public fun onSnapshotState (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/Snapshot; public fun onSnapshotStateWithChildren (Lkotlin/jvm/functions/Function0;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/TreeSnapshot; diff --git a/workflow-testing/build.gradle.kts b/workflow-testing/build.gradle.kts index c471e45c5..efd28001c 100644 --- a/workflow-testing/build.gradle.kts +++ b/workflow-testing/build.gradle.kts @@ -4,6 +4,7 @@ plugins { id("java-library") id("kotlin-jvm") id("published") + id("app.cash.burst") } tasks.withType { diff --git a/workflow-testing/src/test/java/com/squareup/workflow1/ParameterizedTestRunner.kt b/workflow-testing/src/test/java/com/squareup/workflow1/ParameterizedTestRunner.kt deleted file mode 100644 index e2a7f85df..000000000 --- a/workflow-testing/src/test/java/com/squareup/workflow1/ParameterizedTestRunner.kt +++ /dev/null @@ -1,70 +0,0 @@ -package com.squareup.workflow1 - -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertNotSame -import kotlin.test.assertNull -import kotlin.test.assertTrue - -/** - * This file is copied from workflow-runtime:commonTest so our tests that test across the runtime - * look consistent. We could have used a JUnit library like Jupiter, but didn't. - * - * This file is copied so as to avoid creating a workflow-core-testing module (for now). - * - * We do our best to tell you what the parameter was when the failure occured by wrapping - * assertions from kotlin.test and injecting our own message. - */ -@WorkflowExperimentalApi -class ParameterizedTestRunner

{ - - var currentParam: P? = null - - fun runParametrizedTest( - paramSource: Sequence

, - before: () -> Unit = {}, - after: () -> Unit = {}, - test: ParameterizedTestRunner

.(param: P) -> Unit - ) { - paramSource.forEach { - before() - currentParam = it - test(it) - after() - } - } - - fun assertEquals(expected: T, actual: T) { - assertEquals(expected, actual, message = "Using: ${currentParam?.toString()}") - } - - fun assertEquals(expected: T, actual: T, originalMessage: String) { - assertEquals(expected, actual, message = "$originalMessage; Using: ${currentParam?.toString()}") - } - - fun assertTrue(statement: Boolean) { - assertTrue(statement, message = "Using: ${currentParam?.toString()}") - } - - fun assertFalse(statement: Boolean) { - assertFalse(statement, message = "Using: ${currentParam?.toString()}") - } - - inline fun assertFailsWith(block: () -> Unit) { - assertFailsWith(message = "Using: ${currentParam?.toString()}", block) - } - - fun assertNotSame(illegal: T, actual: T) { - assertNotSame(illegal, actual, message = "Using: ${currentParam?.toString()}") - } - - fun assertNotNull(actual: T?) { - assertNotNull(actual, message = "Using: ${currentParam?.toString()}") - } - - fun assertNull(actual: Any?) { - assertNull(actual, message = "Using: ${currentParam?.toString()}") - } -} diff --git a/workflow-testing/src/test/java/com/squareup/workflow1/StatefulWorkflowEventHandlerTest.kt b/workflow-testing/src/test/java/com/squareup/workflow1/StatefulWorkflowEventHandlerTest.kt index d3200b1ed..90fb6057c 100644 --- a/workflow-testing/src/test/java/com/squareup/workflow1/StatefulWorkflowEventHandlerTest.kt +++ b/workflow-testing/src/test/java/com/squareup/workflow1/StatefulWorkflowEventHandlerTest.kt @@ -1,266 +1,249 @@ +@file:Suppress("JUnitMalformedDeclaration") + package com.squareup.workflow1 +import app.cash.burst.Burst +import com.squareup.workflow1.RuntimeConfigOptions.Companion.RENDER_PER_ACTION import com.squareup.workflow1.RuntimeConfigOptions.STABLE_EVENT_HANDLERS import com.squareup.workflow1.testing.WorkflowTestParams import com.squareup.workflow1.testing.launchForTestingFromStartWith import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotSame import kotlin.test.assertSame /** * A lot of duplication here with [StatelessWorkflowEventHandlerTest] */ -@OptIn(WorkflowExperimentalApi::class, WorkflowExperimentalRuntime::class) -class StatefulWorkflowEventHandlerTest { - private data class Params( - val remember: Boolean?, - val runtimeConfig: RuntimeConfig - ) { - val remembering = remember ?: runtimeConfig.contains(STABLE_EVENT_HANDLERS) - } +@OptIn(WorkflowExperimentalRuntime::class) +@Burst +class StatefulWorkflowEventHandlerTest( + private val remembering: Boolean = false, + stableEventHandlers: Boolean = false, +) { - private val rememberValues = sequenceOf(true, false, null) - private val configValues = sequenceOf(emptySet(), setOf(STABLE_EVENT_HANDLERS)) - private val values = rememberValues.flatMap { remember -> - configValues.map { Params(remember, it) } + private val runtimeConfig = if (stableEventHandlers) { + setOf( + STABLE_EVENT_HANDLERS + ) + } else { + RENDER_PER_ACTION } - private val parameterizedTestRunner = ParameterizedTestRunner() @Test fun eventHandler0() { - parameterizedTestRunner.runParametrizedTest(values) { params -> - Workflow.stateful(Unit) { - eventHandler("", remember = params.remembering) { setOutput("yay") } - }.launchForTestingFromStartWith( - testParams = WorkflowTestParams(runtimeConfig = params.runtimeConfig) - ) { - val first = awaitNextRendering() - first.invoke() - assertEquals("yay", awaitNextOutput()) - val next = awaitNextRendering() - if (params.remembering) { - assertSame(first, next) - } else { - assertNotSame(first, next) - } + Workflow.stateful(Unit) { + eventHandler("", remember = remembering) { setOutput("yay") } + }.launchForTestingFromStartWith( + testParams = WorkflowTestParams(runtimeConfig = runtimeConfig) + ) { + val first = awaitNextRendering() + first.invoke() + assertEquals("yay", awaitNextOutput()) + val next = awaitNextRendering() + if (remembering) { + assertSame(first, next) + } else { + assertNotSame(first, next) } } } @Test fun eventHandler1() { - parameterizedTestRunner.runParametrizedTest(values) { params -> - Workflow.stateful Unit>(Unit) { - eventHandler("", remember = params.remembering) { e1 -> - setOutput(e1) - } - }.launchForTestingFromStartWith( - testParams = WorkflowTestParams(runtimeConfig = params.runtimeConfig) - ) { - val first = awaitNextRendering() - first.invoke("yay") - assertEquals("yay", awaitNextOutput()) - val next = awaitNextRendering() - if (params.remembering) { - assertSame(first, next) - } else { - assertNotSame(first, next) - } + Workflow.stateful Unit>(Unit) { + eventHandler("", remember = remembering) { e1 -> + setOutput(e1) + } + }.launchForTestingFromStartWith( + testParams = WorkflowTestParams(runtimeConfig = runtimeConfig) + ) { + val first = awaitNextRendering() + first.invoke("yay") + assertEquals("yay", awaitNextOutput()) + val next = awaitNextRendering() + if (remembering) { + assertSame(first, next) + } else { + assertNotSame(first, next) } } } @Test fun eventHandler2() { - parameterizedTestRunner.runParametrizedTest(values) { params -> - Workflow.stateful Unit>(Unit) { - eventHandler("", remember = params.remembering) { e1, e2 -> - setOutput("$e1-$e2") - } - }.launchForTestingFromStartWith( - testParams = WorkflowTestParams(runtimeConfig = params.runtimeConfig) - ) { - val first = awaitNextRendering() - first.invoke("a", "b") - assertEquals("a-b", awaitNextOutput()) - val next = awaitNextRendering() - if (params.remembering) { - assertSame(first, next) - } else { - assertNotSame(first, next) - } + Workflow.stateful Unit>(Unit) { + eventHandler("", remember = remembering) { e1, e2 -> + setOutput("$e1-$e2") + } + }.launchForTestingFromStartWith( + testParams = WorkflowTestParams(runtimeConfig = runtimeConfig) + ) { + val first = awaitNextRendering() + first.invoke("a", "b") + assertEquals("a-b", awaitNextOutput()) + val next = awaitNextRendering() + if (remembering) { + assertSame(first, next) + } else { + assertNotSame(first, next) } } } @Test fun eventHandler3() { - parameterizedTestRunner.runParametrizedTest(values) { params -> - Workflow.stateful Unit>(Unit) { - eventHandler("", remember = params.remembering) { e1, e2, e3 -> - setOutput("$e1-$e2-$e3") - } - }.launchForTestingFromStartWith( - testParams = WorkflowTestParams(runtimeConfig = params.runtimeConfig) - ) { - val first = awaitNextRendering() - first.invoke("a", "b", "c") - assertEquals("a-b-c", awaitNextOutput()) - val next = awaitNextRendering() - if (params.remembering) { - assertSame(first, next) - } else { - assertNotSame(first, next) - } + Workflow.stateful Unit>(Unit) { + eventHandler("", remember = remembering) { e1, e2, e3 -> + setOutput("$e1-$e2-$e3") + } + }.launchForTestingFromStartWith( + testParams = WorkflowTestParams(runtimeConfig = runtimeConfig) + ) { + val first = awaitNextRendering() + first.invoke("a", "b", "c") + assertEquals("a-b-c", awaitNextOutput()) + val next = awaitNextRendering() + if (remembering) { + assertSame(first, next) + } else { + assertNotSame(first, next) } } } @Test fun eventHandler4() { - parameterizedTestRunner.runParametrizedTest(values) { params -> - Workflow.stateful Unit>(Unit) { - eventHandler("", remember = params.remembering) { e1, e2, e3, e4 -> - setOutput("$e1-$e2-$e3-$e4") - } - }.launchForTestingFromStartWith( - testParams = WorkflowTestParams(runtimeConfig = params.runtimeConfig) - ) { - val first = awaitNextRendering() - first.invoke("a", "b", "c", "d") - assertEquals("a-b-c-d", awaitNextOutput()) - val next = awaitNextRendering() - if (params.remembering) { - assertSame(first, next) - } else { - assertNotSame(first, next) - } + Workflow.stateful Unit>(Unit) { + eventHandler("", remember = remembering) { e1, e2, e3, e4 -> + setOutput("$e1-$e2-$e3-$e4") + } + }.launchForTestingFromStartWith( + testParams = WorkflowTestParams(runtimeConfig = runtimeConfig) + ) { + val first = awaitNextRendering() + first.invoke("a", "b", "c", "d") + assertEquals("a-b-c-d", awaitNextOutput()) + val next = awaitNextRendering() + if (remembering) { + assertSame(first, next) + } else { + assertNotSame(first, next) } } } @Test fun eventHandler5() { - parameterizedTestRunner.runParametrizedTest(values) { params -> - Workflow.stateful Unit>(Unit) { - eventHandler("", remember = params.remembering) { e1, e2, e3, e4, e5 -> - setOutput("$e1-$e2-$e3-$e4-$e5") - } - }.launchForTestingFromStartWith( - testParams = WorkflowTestParams(runtimeConfig = params.runtimeConfig) - ) { - val first = awaitNextRendering() - first.invoke("a", "b", "c", "d", "e") - assertEquals("a-b-c-d-e", awaitNextOutput()) - val next = awaitNextRendering() - if (params.remembering) { - assertSame(first, next) - } else { - assertNotSame(first, next) - } + Workflow.stateful Unit>(Unit) { + eventHandler("", remember = remembering) { e1, e2, e3, e4, e5 -> + setOutput("$e1-$e2-$e3-$e4-$e5") + } + }.launchForTestingFromStartWith( + testParams = WorkflowTestParams(runtimeConfig = runtimeConfig) + ) { + val first = awaitNextRendering() + first.invoke("a", "b", "c", "d", "e") + assertEquals("a-b-c-d-e", awaitNextOutput()) + val next = awaitNextRendering() + if (remembering) { + assertSame(first, next) + } else { + assertNotSame(first, next) } } } @Test fun eventHandler6() { - parameterizedTestRunner.runParametrizedTest(values) { params -> - Workflow.stateful Unit>(Unit) { - eventHandler("", remember = params.remembering) { e1, e2, e3, e4, e5, e6 -> - setOutput("$e1-$e2-$e3-$e4-$e5-$e6") - } - }.launchForTestingFromStartWith( - testParams = WorkflowTestParams(runtimeConfig = params.runtimeConfig) - ) { - val first = awaitNextRendering() - first.invoke("a", "b", "c", "d", "e", "f") - assertEquals("a-b-c-d-e-f", awaitNextOutput()) - val next = awaitNextRendering() - if (params.remembering) { - assertSame(first, next) - } else { - assertNotSame(first, next) - } + Workflow.stateful Unit>(Unit) { + eventHandler("", remember = remembering) { e1, e2, e3, e4, e5, e6 -> + setOutput("$e1-$e2-$e3-$e4-$e5-$e6") + } + }.launchForTestingFromStartWith( + testParams = WorkflowTestParams(runtimeConfig = runtimeConfig) + ) { + val first = awaitNextRendering() + first.invoke("a", "b", "c", "d", "e", "f") + assertEquals("a-b-c-d-e-f", awaitNextOutput()) + val next = awaitNextRendering() + if (remembering) { + assertSame(first, next) + } else { + assertNotSame(first, next) } } } @Test fun eventHandler7() { - parameterizedTestRunner.runParametrizedTest(values) { params -> - Workflow.stateful Unit>(Unit) { - eventHandler("", remember = params.remembering) { e1, e2, e3, e4, e5, e6, e7 -> - setOutput("$e1-$e2-$e3-$e4-$e5-$e6-$e7") - } - }.launchForTestingFromStartWith( - testParams = WorkflowTestParams(runtimeConfig = params.runtimeConfig) - ) { - val first = awaitNextRendering() - first.invoke("a", "b", "c", "d", "e", "f", "g") - assertEquals("a-b-c-d-e-f-g", awaitNextOutput()) - val next = awaitNextRendering() - if (params.remembering) { - assertSame(first, next) - } else { - assertNotSame(first, next) - } + Workflow.stateful Unit>(Unit) { + eventHandler("", remember = remembering) { e1, e2, e3, e4, e5, e6, e7 -> + setOutput("$e1-$e2-$e3-$e4-$e5-$e6-$e7") + } + }.launchForTestingFromStartWith( + testParams = WorkflowTestParams(runtimeConfig = runtimeConfig) + ) { + val first = awaitNextRendering() + first.invoke("a", "b", "c", "d", "e", "f", "g") + assertEquals("a-b-c-d-e-f-g", awaitNextOutput()) + val next = awaitNextRendering() + if (remembering) { + assertSame(first, next) + } else { + assertNotSame(first, next) } } } @Test fun eventHandler8() { - parameterizedTestRunner.runParametrizedTest(values) { params -> - Workflow.stateful Unit>(Unit) { - eventHandler("", remember = params.remembering) { e1, e2, e3, e4, e5, e6, e7, e8 -> - setOutput("$e1-$e2-$e3-$e4-$e5-$e6-$e7-$e8") - } - }.launchForTestingFromStartWith( - testParams = WorkflowTestParams(runtimeConfig = params.runtimeConfig) - ) { - val first = awaitNextRendering() - first.invoke("a", "b", "c", "d", "e", "f", "g", "h") - assertEquals("a-b-c-d-e-f-g-h", awaitNextOutput()) - val next = awaitNextRendering() - if (params.remembering) { - assertSame(first, next) - } else { - assertNotSame(first, next) - } + Workflow.stateful Unit>(Unit) { + eventHandler("", remember = remembering) { e1, e2, e3, e4, e5, e6, e7, e8 -> + setOutput("$e1-$e2-$e3-$e4-$e5-$e6-$e7-$e8") + } + }.launchForTestingFromStartWith( + testParams = WorkflowTestParams(runtimeConfig = runtimeConfig) + ) { + val first = awaitNextRendering() + first.invoke("a", "b", "c", "d", "e", "f", "g", "h") + assertEquals("a-b-c-d-e-f-g-h", awaitNextOutput()) + val next = awaitNextRendering() + if (remembering) { + assertSame(first, next) + } else { + assertNotSame(first, next) } } } @Test fun eventHandler9() { - parameterizedTestRunner.runParametrizedTest(values) { params -> - Workflow.stateful Unit>(Unit) { - eventHandler("", remember = params.remembering) { e1, e2, e3, e4, e5, e6, e7, e8, e9 -> - setOutput("$e1-$e2-$e3-$e4-$e5-$e6-$e7-$e8-$e9") - } - }.launchForTestingFromStartWith( - testParams = WorkflowTestParams(runtimeConfig = params.runtimeConfig) - ) { - val first = awaitNextRendering() - first.invoke("a", "b", "c", "d", "e", "f", "g", "h", "i") - assertEquals("a-b-c-d-e-f-g-h-i", awaitNextOutput()) - val next = awaitNextRendering() - if (params.remembering) { - assertSame(first, next) - } else { - assertNotSame(first, next) - } + Workflow.stateful Unit>(Unit) { + eventHandler("", remember = remembering) { e1, e2, e3, e4, e5, e6, e7, e8, e9 -> + setOutput("$e1-$e2-$e3-$e4-$e5-$e6-$e7-$e8-$e9") + } + }.launchForTestingFromStartWith( + testParams = WorkflowTestParams(runtimeConfig = runtimeConfig) + ) { + val first = awaitNextRendering() + first.invoke("a", "b", "c", "d", "e", "f", "g", "h", "i") + assertEquals("a-b-c-d-e-f-g-h-i", awaitNextOutput()) + val next = awaitNextRendering() + if (remembering) { + assertSame(first, next) + } else { + assertNotSame(first, next) } } } @Test fun eventHandler10() { - parameterizedTestRunner.runParametrizedTest(values) { params -> - Workflow.stateful Unit>(Unit) { - eventHandler("", remember = params.remembering) { e1, e2, e3, e4, e5, e6, e7, e8, e9, e10 -> - setOutput("$e1-$e2-$e3-$e4-$e5-$e6-$e7-$e8-$e9-$e10") - } - }.launchForTestingFromStartWith( - testParams = WorkflowTestParams(runtimeConfig = params.runtimeConfig) - ) { - val first = awaitNextRendering() - first.invoke("a", "b", "c", "d", "e", "f", "g", "h", "i", "k") - assertEquals("a-b-c-d-e-f-g-h-i-k", awaitNextOutput()) - val next = awaitNextRendering() - if (params.remembering) { - assertSame(first, next) - } else { - assertNotSame(first, next) - } + Workflow.stateful Unit>(Unit) { + eventHandler("", remember = remembering) { e1, e2, e3, e4, e5, e6, e7, e8, e9, e10 -> + setOutput("$e1-$e2-$e3-$e4-$e5-$e6-$e7-$e8-$e9-$e10") + } + }.launchForTestingFromStartWith( + testParams = WorkflowTestParams(runtimeConfig = runtimeConfig) + ) { + val first = awaitNextRendering() + first.invoke("a", "b", "c", "d", "e", "f", "g", "h", "i", "k") + assertEquals("a-b-c-d-e-f-g-h-i-k", awaitNextOutput()) + val next = awaitNextRendering() + if (remembering) { + assertSame(first, next) + } else { + assertNotSame(first, next) } } } diff --git a/workflow-testing/src/test/java/com/squareup/workflow1/StatelessWorkflowEventHandlerTest.kt b/workflow-testing/src/test/java/com/squareup/workflow1/StatelessWorkflowEventHandlerTest.kt index 6f0ab5db2..7be50b0b1 100644 --- a/workflow-testing/src/test/java/com/squareup/workflow1/StatelessWorkflowEventHandlerTest.kt +++ b/workflow-testing/src/test/java/com/squareup/workflow1/StatelessWorkflowEventHandlerTest.kt @@ -1,266 +1,249 @@ +@file:Suppress("JUnitMalformedDeclaration") + package com.squareup.workflow1 +import app.cash.burst.Burst +import com.squareup.workflow1.RuntimeConfigOptions.Companion.RENDER_PER_ACTION import com.squareup.workflow1.RuntimeConfigOptions.STABLE_EVENT_HANDLERS import com.squareup.workflow1.testing.WorkflowTestParams import com.squareup.workflow1.testing.launchForTestingFromStartWith import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotSame import kotlin.test.assertSame /** * A lot of duplication here with [StatefulWorkflowEventHandlerTest] */ -@OptIn(WorkflowExperimentalApi::class, WorkflowExperimentalRuntime::class) -class StatelessWorkflowEventHandlerTest { - private data class Params( - val remember: Boolean?, - val runtimeConfig: RuntimeConfig - ) { - val remembering = remember ?: runtimeConfig.contains(STABLE_EVENT_HANDLERS) - } +@OptIn(WorkflowExperimentalRuntime::class) +@Burst +class StatelessWorkflowEventHandlerTest( + private val remembering: Boolean = false, + stableEventHandlers: Boolean = false, +) { - private val rememberValues = sequenceOf(true, false, null) - private val configValues = sequenceOf(emptySet(), setOf(STABLE_EVENT_HANDLERS)) - private val values = rememberValues.flatMap { remember -> - configValues.map { Params(remember, it) } + private val runtimeConfig = if (stableEventHandlers) { + setOf( + STABLE_EVENT_HANDLERS + ) + } else { + RENDER_PER_ACTION } - private val parameterizedTestRunner = ParameterizedTestRunner() @Test fun eventHandler0() { - parameterizedTestRunner.runParametrizedTest(values) { params -> - Workflow.stateless Unit> { - eventHandler("", remember = params.remembering) { setOutput("yay") } - }.launchForTestingFromStartWith( - testParams = WorkflowTestParams(runtimeConfig = params.runtimeConfig) - ) { - val first = awaitNextRendering() - first.invoke() - assertEquals("yay", awaitNextOutput()) - val next = awaitNextRendering() - if (params.remembering) { - assertSame(first, next) - } else { - assertNotSame(first, next) - } + Workflow.stateless Unit> { + eventHandler("", remember = remembering) { setOutput("yay") } + }.launchForTestingFromStartWith( + testParams = WorkflowTestParams(runtimeConfig = runtimeConfig) + ) { + val first = awaitNextRendering() + first.invoke() + assertEquals("yay", awaitNextOutput()) + val next = awaitNextRendering() + if (remembering) { + assertSame(first, next) + } else { + assertNotSame(first, next) } } } @Test fun eventHandler1() { - parameterizedTestRunner.runParametrizedTest(values) { params -> - Workflow.stateless Unit> { - eventHandler("", remember = params.remembering) { e1 -> - setOutput(e1) - } - }.launchForTestingFromStartWith( - testParams = WorkflowTestParams(runtimeConfig = params.runtimeConfig) - ) { - val first = awaitNextRendering() - first.invoke("yay") - assertEquals("yay", awaitNextOutput()) - val next = awaitNextRendering() - if (params.remembering) { - assertSame(first, next) - } else { - assertNotSame(first, next) - } + Workflow.stateless Unit> { + eventHandler("", remember = remembering) { e1 -> + setOutput(e1) + } + }.launchForTestingFromStartWith( + testParams = WorkflowTestParams(runtimeConfig = runtimeConfig) + ) { + val first = awaitNextRendering() + first.invoke("yay") + assertEquals("yay", awaitNextOutput()) + val next = awaitNextRendering() + if (remembering) { + assertSame(first, next) + } else { + assertNotSame(first, next) } } } @Test fun eventHandler2() { - parameterizedTestRunner.runParametrizedTest(values) { params -> - Workflow.stateless Unit> { - eventHandler("", remember = params.remembering) { e1, e2 -> - setOutput("$e1-$e2") - } - }.launchForTestingFromStartWith( - testParams = WorkflowTestParams(runtimeConfig = params.runtimeConfig) - ) { - val first = awaitNextRendering() - first.invoke("a", "b") - assertEquals("a-b", awaitNextOutput()) - val next = awaitNextRendering() - if (params.remembering) { - assertSame(first, next) - } else { - assertNotSame(first, next) - } + Workflow.stateless Unit> { + eventHandler("", remember = remembering) { e1, e2 -> + setOutput("$e1-$e2") + } + }.launchForTestingFromStartWith( + testParams = WorkflowTestParams(runtimeConfig = runtimeConfig) + ) { + val first = awaitNextRendering() + first.invoke("a", "b") + assertEquals("a-b", awaitNextOutput()) + val next = awaitNextRendering() + if (remembering) { + assertSame(first, next) + } else { + assertNotSame(first, next) } } } @Test fun eventHandler3() { - parameterizedTestRunner.runParametrizedTest(values) { params -> - Workflow.stateless Unit> { - eventHandler("", remember = params.remembering) { e1, e2, e3 -> - setOutput("$e1-$e2-$e3") - } - }.launchForTestingFromStartWith( - testParams = WorkflowTestParams(runtimeConfig = params.runtimeConfig) - ) { - val first = awaitNextRendering() - first.invoke("a", "b", "c") - assertEquals("a-b-c", awaitNextOutput()) - val next = awaitNextRendering() - if (params.remembering) { - assertSame(first, next) - } else { - assertNotSame(first, next) - } + Workflow.stateless Unit> { + eventHandler("", remember = remembering) { e1, e2, e3 -> + setOutput("$e1-$e2-$e3") + } + }.launchForTestingFromStartWith( + testParams = WorkflowTestParams(runtimeConfig = runtimeConfig) + ) { + val first = awaitNextRendering() + first.invoke("a", "b", "c") + assertEquals("a-b-c", awaitNextOutput()) + val next = awaitNextRendering() + if (remembering) { + assertSame(first, next) + } else { + assertNotSame(first, next) } } } @Test fun eventHandler4() { - parameterizedTestRunner.runParametrizedTest(values) { params -> - Workflow.stateless Unit> { - eventHandler("", remember = params.remembering) { e1, e2, e3, e4 -> - setOutput("$e1-$e2-$e3-$e4") - } - }.launchForTestingFromStartWith( - testParams = WorkflowTestParams(runtimeConfig = params.runtimeConfig) - ) { - val first = awaitNextRendering() - first.invoke("a", "b", "c", "d") - assertEquals("a-b-c-d", awaitNextOutput()) - val next = awaitNextRendering() - if (params.remembering) { - assertSame(first, next) - } else { - assertNotSame(first, next) - } + Workflow.stateless Unit> { + eventHandler("", remember = remembering) { e1, e2, e3, e4 -> + setOutput("$e1-$e2-$e3-$e4") + } + }.launchForTestingFromStartWith( + testParams = WorkflowTestParams(runtimeConfig = runtimeConfig) + ) { + val first = awaitNextRendering() + first.invoke("a", "b", "c", "d") + assertEquals("a-b-c-d", awaitNextOutput()) + val next = awaitNextRendering() + if (remembering) { + assertSame(first, next) + } else { + assertNotSame(first, next) } } } @Test fun eventHandler5() { - parameterizedTestRunner.runParametrizedTest(values) { params -> - Workflow.stateless Unit> { - eventHandler("", remember = params.remembering) { e1, e2, e3, e4, e5 -> - setOutput("$e1-$e2-$e3-$e4-$e5") - } - }.launchForTestingFromStartWith( - testParams = WorkflowTestParams(runtimeConfig = params.runtimeConfig) - ) { - val first = awaitNextRendering() - first.invoke("a", "b", "c", "d", "e") - assertEquals("a-b-c-d-e", awaitNextOutput()) - val next = awaitNextRendering() - if (params.remembering) { - assertSame(first, next) - } else { - assertNotSame(first, next) - } + Workflow.stateless Unit> { + eventHandler("", remember = remembering) { e1, e2, e3, e4, e5 -> + setOutput("$e1-$e2-$e3-$e4-$e5") + } + }.launchForTestingFromStartWith( + testParams = WorkflowTestParams(runtimeConfig = runtimeConfig) + ) { + val first = awaitNextRendering() + first.invoke("a", "b", "c", "d", "e") + assertEquals("a-b-c-d-e", awaitNextOutput()) + val next = awaitNextRendering() + if (remembering) { + assertSame(first, next) + } else { + assertNotSame(first, next) } } } @Test fun eventHandler6() { - parameterizedTestRunner.runParametrizedTest(values) { params -> - Workflow.stateless Unit> { - eventHandler("", remember = params.remembering) { e1, e2, e3, e4, e5, e6 -> - setOutput("$e1-$e2-$e3-$e4-$e5-$e6") - } - }.launchForTestingFromStartWith( - testParams = WorkflowTestParams(runtimeConfig = params.runtimeConfig) - ) { - val first = awaitNextRendering() - first.invoke("a", "b", "c", "d", "e", "f") - assertEquals("a-b-c-d-e-f", awaitNextOutput()) - val next = awaitNextRendering() - if (params.remembering) { - assertSame(first, next) - } else { - assertNotSame(first, next) - } + Workflow.stateless Unit> { + eventHandler("", remember = remembering) { e1, e2, e3, e4, e5, e6 -> + setOutput("$e1-$e2-$e3-$e4-$e5-$e6") + } + }.launchForTestingFromStartWith( + testParams = WorkflowTestParams(runtimeConfig = runtimeConfig) + ) { + val first = awaitNextRendering() + first.invoke("a", "b", "c", "d", "e", "f") + assertEquals("a-b-c-d-e-f", awaitNextOutput()) + val next = awaitNextRendering() + if (remembering) { + assertSame(first, next) + } else { + assertNotSame(first, next) } } } @Test fun eventHandler7() { - parameterizedTestRunner.runParametrizedTest(values) { params -> - Workflow.stateless Unit> { - eventHandler("", remember = params.remembering) { e1, e2, e3, e4, e5, e6, e7 -> - setOutput("$e1-$e2-$e3-$e4-$e5-$e6-$e7") - } - }.launchForTestingFromStartWith( - testParams = WorkflowTestParams(runtimeConfig = params.runtimeConfig) - ) { - val first = awaitNextRendering() - first.invoke("a", "b", "c", "d", "e", "f", "g") - assertEquals("a-b-c-d-e-f-g", awaitNextOutput()) - val next = awaitNextRendering() - if (params.remembering) { - assertSame(first, next) - } else { - assertNotSame(first, next) - } + Workflow.stateless Unit> { + eventHandler("", remember = remembering) { e1, e2, e3, e4, e5, e6, e7 -> + setOutput("$e1-$e2-$e3-$e4-$e5-$e6-$e7") + } + }.launchForTestingFromStartWith( + testParams = WorkflowTestParams(runtimeConfig = runtimeConfig) + ) { + val first = awaitNextRendering() + first.invoke("a", "b", "c", "d", "e", "f", "g") + assertEquals("a-b-c-d-e-f-g", awaitNextOutput()) + val next = awaitNextRendering() + if (remembering) { + assertSame(first, next) + } else { + assertNotSame(first, next) } } } @Test fun eventHandler8() { - parameterizedTestRunner.runParametrizedTest(values) { params -> - Workflow.stateless Unit> { - eventHandler("", remember = params.remembering) { e1, e2, e3, e4, e5, e6, e7, e8 -> - setOutput("$e1-$e2-$e3-$e4-$e5-$e6-$e7-$e8") - } - }.launchForTestingFromStartWith( - testParams = WorkflowTestParams(runtimeConfig = params.runtimeConfig) - ) { - val first = awaitNextRendering() - first.invoke("a", "b", "c", "d", "e", "f", "g", "h") - assertEquals("a-b-c-d-e-f-g-h", awaitNextOutput()) - val next = awaitNextRendering() - if (params.remembering) { - assertSame(first, next) - } else { - assertNotSame(first, next) - } + Workflow.stateless Unit> { + eventHandler("", remember = remembering) { e1, e2, e3, e4, e5, e6, e7, e8 -> + setOutput("$e1-$e2-$e3-$e4-$e5-$e6-$e7-$e8") + } + }.launchForTestingFromStartWith( + testParams = WorkflowTestParams(runtimeConfig = runtimeConfig) + ) { + val first = awaitNextRendering() + first.invoke("a", "b", "c", "d", "e", "f", "g", "h") + assertEquals("a-b-c-d-e-f-g-h", awaitNextOutput()) + val next = awaitNextRendering() + if (remembering) { + assertSame(first, next) + } else { + assertNotSame(first, next) } } } @Test fun eventHandler9() { - parameterizedTestRunner.runParametrizedTest(values) { params -> - Workflow.stateless Unit> { - eventHandler("", remember = params.remembering) { e1, e2, e3, e4, e5, e6, e7, e8, e9 -> - setOutput("$e1-$e2-$e3-$e4-$e5-$e6-$e7-$e8-$e9") - } - }.launchForTestingFromStartWith( - testParams = WorkflowTestParams(runtimeConfig = params.runtimeConfig) - ) { - val first = awaitNextRendering() - first.invoke("a", "b", "c", "d", "e", "f", "g", "h", "i") - assertEquals("a-b-c-d-e-f-g-h-i", awaitNextOutput()) - val next = awaitNextRendering() - if (params.remembering) { - assertSame(first, next) - } else { - assertNotSame(first, next) - } + Workflow.stateless Unit> { + eventHandler("", remember = remembering) { e1, e2, e3, e4, e5, e6, e7, e8, e9 -> + setOutput("$e1-$e2-$e3-$e4-$e5-$e6-$e7-$e8-$e9") + } + }.launchForTestingFromStartWith( + testParams = WorkflowTestParams(runtimeConfig = runtimeConfig) + ) { + val first = awaitNextRendering() + first.invoke("a", "b", "c", "d", "e", "f", "g", "h", "i") + assertEquals("a-b-c-d-e-f-g-h-i", awaitNextOutput()) + val next = awaitNextRendering() + if (remembering) { + assertSame(first, next) + } else { + assertNotSame(first, next) } } } @Test fun eventHandler10() { - parameterizedTestRunner.runParametrizedTest(values) { params -> - Workflow.stateless Unit> { - eventHandler("", remember = params.remembering) { e1, e2, e3, e4, e5, e6, e7, e8, e9, e10 -> - setOutput("$e1-$e2-$e3-$e4-$e5-$e6-$e7-$e8-$e9-$e10") - } - }.launchForTestingFromStartWith( - testParams = WorkflowTestParams(runtimeConfig = params.runtimeConfig) - ) { - val first = awaitNextRendering() - first.invoke("a", "b", "c", "d", "e", "f", "g", "h", "i", "k") - assertEquals("a-b-c-d-e-f-g-h-i-k", awaitNextOutput()) - val next = awaitNextRendering() - if (params.remembering) { - assertSame(first, next) - } else { - assertNotSame(first, next) - } + Workflow.stateless Unit> { + eventHandler("", remember = remembering) { e1, e2, e3, e4, e5, e6, e7, e8, e9, e10 -> + setOutput("$e1-$e2-$e3-$e4-$e5-$e6-$e7-$e8-$e9-$e10") + } + }.launchForTestingFromStartWith( + testParams = WorkflowTestParams(runtimeConfig = runtimeConfig) + ) { + val first = awaitNextRendering() + first.invoke("a", "b", "c", "d", "e", "f", "g", "h", "i", "k") + assertEquals("a-b-c-d-e-f-g-h-i-k", awaitNextOutput()) + val next = awaitNextRendering() + if (remembering) { + assertSame(first, next) + } else { + assertNotSame(first, next) } } } diff --git a/workflow-testing/src/test/java/com/squareup/workflow1/WorkflowsLifecycleTests.kt b/workflow-testing/src/test/java/com/squareup/workflow1/WorkflowsLifecycleTests.kt index 5f3b6841b..c45b4393d 100644 --- a/workflow-testing/src/test/java/com/squareup/workflow1/WorkflowsLifecycleTests.kt +++ b/workflow-testing/src/test/java/com/squareup/workflow1/WorkflowsLifecycleTests.kt @@ -1,8 +1,12 @@ +@file:Suppress("JUnitMalformedDeclaration") + package com.squareup.workflow1 +import app.cash.burst.Burst import com.squareup.workflow1.RuntimeConfigOptions.CONFLATE_STALE_RENDERINGS -import com.squareup.workflow1.RuntimeConfigOptions.PARTIAL_TREE_RENDERING -import com.squareup.workflow1.RuntimeConfigOptions.RENDER_ONLY_WHEN_STATE_CHANGES +import com.squareup.workflow1.RuntimeConfigOptions.Companion.RuntimeOptions +import com.squareup.workflow1.RuntimeConfigOptions.Companion.RuntimeOptions.DEFAULT +import com.squareup.workflow1.RuntimeConfigOptions.DRAIN_EXCLUSIVE_ACTIONS import com.squareup.workflow1.testing.headlessIntegrationTest import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job @@ -10,23 +14,19 @@ import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlin.test.Ignore import kotlin.test.Test +import kotlin.test.assertEquals /** * Most of these tests are motivated by [1093](https://github.com/square/workflow-kotlin/issues/1093). */ @OptIn(WorkflowExperimentalRuntime::class, WorkflowExperimentalApi::class) -class WorkflowsLifecycleTests { - - private val runtimeOptions: Sequence = arrayOf( - RuntimeConfigOptions.RENDER_PER_ACTION, - setOf(RENDER_ONLY_WHEN_STATE_CHANGES), - setOf(CONFLATE_STALE_RENDERINGS), - setOf(CONFLATE_STALE_RENDERINGS, RENDER_ONLY_WHEN_STATE_CHANGES), - setOf(RENDER_ONLY_WHEN_STATE_CHANGES, PARTIAL_TREE_RENDERING), - setOf(CONFLATE_STALE_RENDERINGS, RENDER_ONLY_WHEN_STATE_CHANGES, PARTIAL_TREE_RENDERING), - ).asSequence() - - private val runtimeTestRunner = ParameterizedTestRunner() +@Burst +class WorkflowsLifecycleTests( + private val runtime: RuntimeOptions = DEFAULT +) { + + private val runtimeConfig = runtime.runtimeConfig + private var started = 0 private var cancelled = 0 private val workflowWithSideEffects: @@ -84,62 +84,44 @@ class WorkflowsLifecycleTests { } @Test fun sideEffectsStartedWhenExpected() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - after = ::cleanup, - ) { runtimeConfig: RuntimeConfig -> - - workflowWithSideEffects.headlessIntegrationTest( - runtimeConfig = runtimeConfig - ) { - // One time starts but does not stop the side effect. - repeat(1) { - val (current, setState) = awaitNextRendering() - setState.invoke(current + 1) - } - - assertEquals(1, started, "Side Effect not started 1 time.") + workflowWithSideEffects.headlessIntegrationTest( + runtimeConfig = runtimeConfig + ) { + // One time starts but does not stop the side effect. + repeat(1) { + val (current, setState) = awaitNextRendering() + setState.invoke(current + 1) } + + assertEquals(1, started, "Side Effect not started 1 time.") } } @Test fun sideEffectsStoppedWhenExpected() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - after = ::cleanup, - ) { runtimeConfig: RuntimeConfig -> - - workflowWithSideEffects.headlessIntegrationTest( - runtimeConfig = runtimeConfig - ) { - // Twice will start and stop the side effect. - repeat(2) { - val (current, setState) = awaitNextRendering() - setState.invoke(current + 1) - } - assertEquals(1, started, "Side Effect not started 1 time.") - assertEquals(1, cancelled, "Side Effect not cancelled 1 time.") + workflowWithSideEffects.headlessIntegrationTest( + runtimeConfig = runtimeConfig + ) { + // Twice will start and stop the side effect. + repeat(2) { + val (current, setState) = awaitNextRendering() + setState.invoke(current + 1) } + assertEquals(1, started, "Side Effect not started 1 time.") + assertEquals(1, cancelled, "Side Effect not cancelled 1 time.") } } @Test fun childSessionWorkflowStartedWhenExpected() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - after = ::cleanup, - ) { runtimeConfig: RuntimeConfig -> - - workflowWithChildSession.headlessIntegrationTest( - runtimeConfig = runtimeConfig - ) { - // One time starts but does not stop the child session workflow. - repeat(1) { - val (current, setState) = awaitNextRendering() - setState.invoke(current + 1) - } - - assertEquals(1, started, "Child Session Workflow not started 1 time.") + workflowWithChildSession.headlessIntegrationTest( + runtimeConfig = runtimeConfig + ) { + // One time starts but does not stop the child session workflow. + repeat(1) { + val (current, setState) = awaitNextRendering() + setState.invoke(current + 1) } + + assertEquals(1, started, "Child Session Workflow not started 1 time.") } } @@ -156,51 +138,41 @@ class WorkflowsLifecycleTests { @OptIn(ExperimentalCoroutinesApi::class) @Test fun sideEffectsStartAndStoppedWhenHandledSynchronously() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - after = ::cleanup, - ) { runtimeConfig: RuntimeConfig -> - - val dispatcher = UnconfinedTestDispatcher() - workflowWithSideEffects.headlessIntegrationTest( - coroutineContext = dispatcher, - runtimeConfig = runtimeConfig + val dispatcher = UnconfinedTestDispatcher() + workflowWithSideEffects.headlessIntegrationTest( + coroutineContext = dispatcher, + runtimeConfig = runtimeConfig + ) { + + val (_, setState) = awaitNextRendering() + // 2 actions queued up - should start the side effect and then stop it + // on two consecutive render passes. + setState.invoke(1) + setState.invoke(2) + awaitNextRendering() + if (!runtimeConfig.contains(CONFLATE_STALE_RENDERINGS) && + !runtimeConfig.contains(DRAIN_EXCLUSIVE_ACTIONS) ) { - - val (_, setState) = awaitNextRendering() - // 2 actions queued up - should start the side effect and then stop it - // on two consecutive render passes. - setState.invoke(1) - setState.invoke(2) + // 2 rendering or 1 depending on runtime config. awaitNextRendering() - if (!runtimeConfig.contains(CONFLATE_STALE_RENDERINGS)) { - // 2 rendering or 1 depending on runtime config. - awaitNextRendering() - } - - assertEquals(1, started, "Side Effect not started 1 time.") - assertEquals(1, cancelled, "Side Effect not cancelled 1 time.") } + + assertEquals(1, started, "Side Effect not started 1 time.") + assertEquals(1, cancelled, "Side Effect not cancelled 1 time.") } } @Test fun childSessionWorkflowStoppedWhenExpected() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - after = ::cleanup, - ) { runtimeConfig: RuntimeConfig -> - - workflowWithChildSession.headlessIntegrationTest( - runtimeConfig = runtimeConfig - ) { - // Twice will start and stop the child session workflow. - repeat(2) { - val (current, setState) = awaitNextRendering() - setState.invoke(current + 1) - } - assertEquals(1, started, "Child Session Workflow not started 1 time.") - assertEquals(1, cancelled, "Child Session Workflow not cancelled 1 time.") + workflowWithChildSession.headlessIntegrationTest( + runtimeConfig = runtimeConfig + ) { + // Twice will start and stop the child session workflow. + repeat(2) { + val (current, setState) = awaitNextRendering() + setState.invoke(current + 1) } + assertEquals(1, started, "Child Session Workflow not started 1 time.") + assertEquals(1, cancelled, "Child Session Workflow not cancelled 1 time.") } } @@ -212,31 +184,25 @@ class WorkflowsLifecycleTests { @OptIn(ExperimentalCoroutinesApi::class) @Test fun childSessionWorkflowStartAndStoppedWhenHandledSynchronously() { - runtimeTestRunner.runParametrizedTest( - paramSource = runtimeOptions, - after = ::cleanup, - ) { runtimeConfig: RuntimeConfig -> - - val dispatcher = UnconfinedTestDispatcher() - workflowWithChildSession.headlessIntegrationTest( - coroutineContext = dispatcher, - runtimeConfig = runtimeConfig - ) { - - val (_, setState) = awaitNextRendering() - // 2 actions queued up - should start the child session workflow and then stop it - // on two consecutive render passes, synchronously. - setState.invoke(1) - setState.invoke(2) + val dispatcher = UnconfinedTestDispatcher() + workflowWithChildSession.headlessIntegrationTest( + coroutineContext = dispatcher, + runtimeConfig = runtimeConfig + ) { + + val (_, setState) = awaitNextRendering() + // 2 actions queued up - should start the child session workflow and then stop it + // on two consecutive render passes, synchronously. + setState.invoke(1) + setState.invoke(2) + awaitNextRendering() + if (!runtimeConfig.contains(CONFLATE_STALE_RENDERINGS)) { + // 2 rendering or 1 depending on runtime config. awaitNextRendering() - if (!runtimeConfig.contains(CONFLATE_STALE_RENDERINGS)) { - // 2 rendering or 1 depending on runtime config. - awaitNextRendering() - } - - assertEquals(1, started, "Child Session Workflow not started 1 time.") - assertEquals(1, cancelled, "Child Session Workflow not cancelled 1 time.") } + + assertEquals(1, started, "Child Session Workflow not started 1 time.") + assertEquals(1, cancelled, "Child Session Workflow not cancelled 1 time.") } } } diff --git a/workflow-trace-viewer/README.md b/workflow-trace-viewer/README.md index 98b5e424c..57735624a 100644 --- a/workflow-trace-viewer/README.md +++ b/workflow-trace-viewer/README.md @@ -9,3 +9,15 @@ It can be run via Gradle using: ```shell ./gradlew :workflow-trace-viewer:run ``` + +### Terminology + +**Trace**: A trace is a file — made up of frames — that contains the execution history of a Workflow. It includes information about render passes, how states have changed within workflows, and the specific props being passed through. The data collected to generate these should be in chronological order, and allows developers to step through the process easily. + +**Frame**: Essentially a "snapshot" of the current "state" of the whole Workflow tree. It contains relevant information about the changes in workflow states and how props are passed throughout. + +- Note that "snapshot" and "state" are different from `snapshotState` and `State`, which are idiomatic to the Workflow library. + +### External Libraries + +[FileKit](https://github.com/vinceglb/FileKit) is an external library made to apply file operations on Kotlin and KMP projects. It's purpose in this app is to allow developers to upload their own json trace files. The motivation for its use is to quickly implement a file picker. diff --git a/workflow-trace-viewer/api/workflow-trace-viewer.api b/workflow-trace-viewer/api/workflow-trace-viewer.api index 783954eb6..c06abc9a6 100644 --- a/workflow-trace-viewer/api/workflow-trace-viewer.api +++ b/workflow-trace-viewer/api/workflow-trace-viewer.api @@ -1,5 +1,5 @@ public final class com/squareup/workflow1/traceviewer/AppKt { - public static final fun App (Landroidx/compose/runtime/Composer;I)V + public static final fun App (Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V } public final class com/squareup/workflow1/traceviewer/ComposableSingletons$MainKt { @@ -14,3 +14,57 @@ public final class com/squareup/workflow1/traceviewer/MainKt { public static synthetic fun main ([Ljava/lang/String;)V } +public final class com/squareup/workflow1/traceviewer/model/Node { + public static final field $stable I + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;)V + public final fun getChildren ()Ljava/util/List; + public final fun getId ()Ljava/lang/String; + public final fun getName ()Ljava/lang/String; +} + +public final class com/squareup/workflow1/traceviewer/ui/FrameSelectTabKt { + public static final fun FrameSelectTab (Ljava/util/List;ILkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V +} + +public final class com/squareup/workflow1/traceviewer/ui/WorkflowInfoPanelKt { + public static final fun RightInfoPanel (Lcom/squareup/workflow1/traceviewer/model/Node;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V +} + +public final class com/squareup/workflow1/traceviewer/ui/WorkflowTreeKt { + public static final fun RenderDiagram (Lio/github/vinceglb/filekit/PlatformFile;ILkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V +} + +public final class com/squareup/workflow1/traceviewer/util/ComposableSingletons$UploadFileKt { + public static final field INSTANCE Lcom/squareup/workflow1/traceviewer/util/ComposableSingletons$UploadFileKt; + public static field lambda-1 Lkotlin/jvm/functions/Function3; + public fun ()V + public final fun getLambda-1$wf1_workflow_trace_viewer ()Lkotlin/jvm/functions/Function3; +} + +public final class com/squareup/workflow1/traceviewer/util/JsonParserKt { + public static final fun parseTrace (Lio/github/vinceglb/filekit/PlatformFile;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class com/squareup/workflow1/traceviewer/util/ParseResult { +} + +public final class com/squareup/workflow1/traceviewer/util/ParseResult$Failure : com/squareup/workflow1/traceviewer/util/ParseResult { + public static final field $stable I + public fun (Ljava/lang/Throwable;)V + public final fun getError ()Ljava/lang/Throwable; +} + +public final class com/squareup/workflow1/traceviewer/util/ParseResult$Success : com/squareup/workflow1/traceviewer/util/ParseResult { + public static final field $stable I + public fun (Ljava/util/List;)V + public final fun getTrace ()Ljava/util/List; +} + +public final class com/squareup/workflow1/traceviewer/util/SandboxBackgroundKt { + public static final fun SandboxBackground (Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V +} + +public final class com/squareup/workflow1/traceviewer/util/UploadFileKt { + public static final fun UploadFile (Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V +} + diff --git a/workflow-trace-viewer/build.gradle.kts b/workflow-trace-viewer/build.gradle.kts index 61e4fb13d..c0a63cc87 100644 --- a/workflow-trace-viewer/build.gradle.kts +++ b/workflow-trace-viewer/build.gradle.kts @@ -23,6 +23,8 @@ kotlin { implementation(compose.desktop.currentOs) implementation(libs.kotlinx.coroutines.swing) implementation(compose.materialIconsExtended) + implementation(libs.squareup.moshi.kotlin) + implementation(libs.filekit.dialogs.compose) } } } diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt index 8a82d4609..2bcdb7a8c 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/App.kt @@ -1,9 +1,66 @@ package com.squareup.workflow1.traceviewer -import androidx.compose.material.Text +import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.squareup.workflow1.traceviewer.model.Node +import com.squareup.workflow1.traceviewer.ui.FrameSelectTab +import com.squareup.workflow1.traceviewer.ui.RenderDiagram +import com.squareup.workflow1.traceviewer.ui.RightInfoPanel +import com.squareup.workflow1.traceviewer.util.SandboxBackground +import com.squareup.workflow1.traceviewer.util.UploadFile +import io.github.vinceglb.filekit.PlatformFile +/** + * Main composable that provides the different layers of UI. + */ @Composable -fun App() { - Text("Hello world!") +public fun App( + modifier: Modifier = Modifier +) { + var selectedTraceFile by remember { mutableStateOf(null) } + var selectedNode by remember { mutableStateOf(null) } + var workflowFrames by remember { mutableStateOf>(emptyList()) } + var frameIndex by remember { mutableIntStateOf(0) } + + Box( + modifier = modifier + ) { + // Main content + if (selectedTraceFile != null) { + SandboxBackground { + RenderDiagram( + traceFile = selectedTraceFile!!, + frameInd = frameIndex, + onFileParse = { workflowFrames = it }, + onNodeSelect = { selectedNode = it } + ) + } + } + + FrameSelectTab( + frames = workflowFrames, + currentIndex = frameIndex, + onIndexChange = { frameIndex = it }, + modifier = Modifier.align(Alignment.TopCenter) + ) + + RightInfoPanel(selectedNode) + + // The states are reset when a new file is selected. + UploadFile( + onFileSelect = { + selectedTraceFile = it + selectedNode = null + frameIndex = 0 + }, + modifier = Modifier.align(Alignment.BottomStart) + ) + } } diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/Main.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/Main.kt index 63ebd1cc3..485c98c12 100644 --- a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/Main.kt +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/Main.kt @@ -1,9 +1,14 @@ package com.squareup.workflow1.traceviewer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier import androidx.compose.ui.window.singleWindowApplication +/** + * Main entry point for the desktop application, see [README.md] for more details. + */ fun main() { singleWindowApplication(title = "Workflow Trace Viewer") { - App() + App(Modifier.fillMaxSize()) } } diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/model/Node.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/model/Node.kt new file mode 100644 index 000000000..412eb5a7c --- /dev/null +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/model/Node.kt @@ -0,0 +1,14 @@ +package com.squareup.workflow1.traceviewer.model + +/** + * Since the logic of Workflow is hierarchical (where each workflow may have parent workflows and/or + * children workflows, a tree structure is most appropriate for representing the data rather than + * using flat data structures like an array. + * + * TBD what more metadata should be involved with each node, e.g. (props, states, # of render passes) + */ +public class Node( + val id: String, + val name: String, + val children: List +) diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/FrameSelectTab.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/FrameSelectTab.kt new file mode 100644 index 000000000..0dccfa4e8 --- /dev/null +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/FrameSelectTab.kt @@ -0,0 +1,51 @@ +package com.squareup.workflow1.traceviewer.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.squareup.workflow1.traceviewer.model.Node + +/** + * A trace tab selector that allows devs to switch between different states within the provided trace. + */ +@Composable +public fun FrameSelectTab( + frames: List, + currentIndex: Int, + onIndexChange: (Int) -> Unit, + modifier: Modifier = Modifier +) { + val state = rememberLazyListState() + + Surface( + modifier = modifier + .padding(4.dp), + color = Color.White, + ) { + LazyRow( + modifier = Modifier + .padding(8.dp), + state = state + ) { + items(frames.size) { index -> + Text( + text = "State ${index + 1}", + color = if (index == currentIndex) Color.Black else Color.LightGray, + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .clickable { onIndexChange(index) } + .padding(10.dp) + ) + } + } + } +} diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowInfoPanel.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowInfoPanel.kt new file mode 100644 index 000000000..f7c959abe --- /dev/null +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowInfoPanel.kt @@ -0,0 +1,93 @@ +package com.squareup.workflow1.traceviewer.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons.AutoMirrored.Filled +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.squareup.workflow1.traceviewer.model.Node + +/** + * A panel that displays information about the selected workflow node. + * It can be toggled open or closed, and resets when the user selects a new file + * + * @param selectedNode The currently selected workflow node, or null if no node is selected. + */ +@Composable +public fun RightInfoPanel( + selectedNode: Node?, + modifier: Modifier = Modifier +) { + // This row is aligned to the right of the screen. + Row { + Spacer(modifier = Modifier.weight(1f)) + + var panelOpen by remember { mutableStateOf(false) } + + IconButton( + onClick = { panelOpen = !panelOpen }, + modifier = Modifier + .padding(8.dp) + .size(30.dp) + .align(Alignment.Top) + ) { + Icon( + imageVector = if (panelOpen) Filled.KeyboardArrowRight else Filled.KeyboardArrowLeft, + contentDescription = if (panelOpen) "Close Panel" else "Open Panel", + modifier = Modifier + ) + } + + if (panelOpen) { + NodePanelDetails( + selectedNode, + Modifier.fillMaxWidth(.35f) + ) + } + } +} + +@Composable +private fun NodePanelDetails( + node: Node?, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxHeight() + .background(Color.LightGray) + .padding(8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (node == null) { + Text("No node selected") + return@Column + } + + Text("only visible with a node selected") + Text( + text = "This is a node panel for ${node.name}", + fontSize = 20.sp, + modifier = Modifier.padding(8.dp) + ) + } +} diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt new file mode 100644 index 000000000..59277e82f --- /dev/null +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowTree.kt @@ -0,0 +1,123 @@ +package com.squareup.workflow1.traceviewer.ui + +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.squareup.workflow1.traceviewer.model.Node +import com.squareup.workflow1.traceviewer.util.ParseResult +import com.squareup.workflow1.traceviewer.util.parseTrace +import io.github.vinceglb.filekit.PlatformFile + +/** + * Access point for drawing the main content of the app. It will load the trace for given files and + * tabs. This will also all errors related to errors parsing a given trace JSON file. + */ +@Composable +public fun RenderDiagram( + traceFile: PlatformFile, + frameInd: Int, + onFileParse: (List) -> Unit, + onNodeSelect: (Node) -> Unit, + modifier: Modifier = Modifier +) { + var frames by remember { mutableStateOf>(emptyList()) } + var isLoading by remember(traceFile) { mutableStateOf(true) } + var error by remember(traceFile) { mutableStateOf(null) } + + LaunchedEffect(traceFile) { + val parseResult = parseTrace(traceFile) + + when (parseResult) { + is ParseResult.Failure -> { + error = parseResult.error + } + is ParseResult.Success -> { + val parsedFrames = parseResult.trace ?: emptyList() + frames = parsedFrames + onFileParse(parsedFrames) + isLoading = false + } + } + } + + if (error != null) { + Text("Error parsing file: ${error?.message}") + return + } + + if (!isLoading) { + DrawTree(frames[frameInd], onNodeSelect) + } + + // TODO: catch errors and display UI here +} + +/** + * Since the workflow nodes present a tree structure, we utilize a recursive function to draw the tree + * The Column holds a subtree of nodes, and the Row holds all the children of the current node + */ +@Composable +private fun DrawTree( + node: Node, + onNodeSelect: (Node) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier + .padding(5.dp) + .border(1.dp, Color.Black) + .fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + DrawNode(node, onNodeSelect) + + // Draws the node's children recursively. + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.Top + ) { + node.children.forEach { childNode -> + DrawTree(childNode, onNodeSelect) + } + } + } +} + +/** + * A basic box that represents a workflow node + */ +@Composable +private fun DrawNode( + node: Node, + onNodeSelect: (Node) -> Unit, +) { + Box( + modifier = Modifier + .clickable { + // Selecting a node will bubble back up to the main view to handle the selection + onNodeSelect(node) + } + .padding(10.dp) + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(text = node.name) + Text(text = "ID: ${node.id}") + } + } +} diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt new file mode 100644 index 000000000..6c1b7e257 --- /dev/null +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/JsonParser.kt @@ -0,0 +1,37 @@ +package com.squareup.workflow1.traceviewer.util + +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import com.squareup.workflow1.traceviewer.model.Node +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.readString + +/** + * Parses a given file's JSON String into [Node] with Moshi adapters. + * + * @return A [ParseResult] representing result of parsing, either an error related to the + * format of the JSON, or a success and a parsed trace. + */ +public suspend fun parseTrace( + file: PlatformFile, +): ParseResult { + return try { + val jsonString = file.readString() + val moshi = Moshi.Builder() + .add(KotlinJsonAdapterFactory()) + .build() + val workflowList = Types.newParameterizedType(List::class.java, Node::class.java) + val workflowAdapter: JsonAdapter> = moshi.adapter(workflowList) + val trace = workflowAdapter.fromJson(jsonString) + ParseResult.Success(trace) + } catch (e: Exception) { + ParseResult.Failure(e) + } +} + +sealed interface ParseResult { + class Success(val trace: List?) : ParseResult + class Failure(val error: Throwable) : ParseResult +} diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SandboxBackground.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SandboxBackground.kt new file mode 100644 index 000000000..822584c8e --- /dev/null +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/SandboxBackground.kt @@ -0,0 +1,72 @@ +package com.squareup.workflow1.traceviewer.util + +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.pointerInput + +/** + * This is the backdrop for the whole app. Since there can be hundreds of modules at a time, there + * is not realistic way to fit everything on the screen at once. Having the liberty to pan across + * the whole tree as well as zoom into specific subtrees means there's a lot more control when + * analyzing the traces. + * + */ +@Composable +public fun SandboxBackground( + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + var scale by remember { mutableFloatStateOf(1f) } + var offset by remember { mutableStateOf(Offset.Zero) } + + Box( + modifier + .fillMaxSize() + .pointerInput(Unit) { + // Panning capabilities: watches for drag gestures and applies the translation + detectDragGestures { _, translation -> + offset += translation + } + } + .pointerInput(Unit) { + // Zooming capabilities: watches for any scroll events and immediately consumes changes. + // - This is AI generated. + awaitEachGesture { + val event = awaitPointerEvent() + if (event.type == PointerEventType.Scroll) { + val scrollDelta = event.changes.first().scrollDelta.y + scale *= if (scrollDelta < 0) 1.1f else 0.9f + scale = scale.coerceIn(0.1f, 10f) + event.changes.forEach { it.consume() } + } + } + } + ) { + Box( + modifier = Modifier + .wrapContentSize(unbounded = true, align = Alignment.Center) + .graphicsLayer { + translationX = offset.x + translationY = offset.y + scaleX = scale + scaleY = scale + } + ) { + content() + } + } +} diff --git a/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/UploadFile.kt b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/UploadFile.kt new file mode 100644 index 000000000..4a197e8f4 --- /dev/null +++ b/workflow-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/util/UploadFile.kt @@ -0,0 +1,46 @@ +package com.squareup.workflow1.traceviewer.util + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults.buttonColors +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitType +import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher + +/** + * Provides functionality for user to upload a JSON or .txt file from their local devices, which + * contains information pulled from workflow traces + */ +@Composable +public fun UploadFile( + onFileSelect: (PlatformFile?) -> Unit, + modifier: Modifier = Modifier, +) { + val launcher = rememberFilePickerLauncher( + type = FileKitType.File(listOf("json", "txt")), + title = "Select Workflow Trace File" + ) { + onFileSelect(it) + } + + Button( + onClick = { launcher.launch() }, + modifier = modifier.padding(16.dp), + shape = CircleShape, + colors = buttonColors(Color.Black) + ) { + Text( + text = "+", + color = Color.White, + fontSize = 24.sp, + fontWeight = androidx.compose.ui.text.font.FontWeight.Bold + ) + } +} diff --git a/workflow-trace-viewer/src/jvmMain/resources/workflow-20.json b/workflow-trace-viewer/src/jvmMain/resources/workflow-20.json new file mode 100644 index 000000000..241524796 --- /dev/null +++ b/workflow-trace-viewer/src/jvmMain/resources/workflow-20.json @@ -0,0 +1,107 @@ +[ +{ + "id": 1, + "name": "root", + "children": [ + { + "id": 2, + "name": "auth-flow", + "children": [ + { + "id": 3, + "name": "login-screen", + "children": [ + { + "id": 4, + "name": "login-form", + "children": [] + }, + { + "id": 5, + "name": "social-login", + "children": [] + } + ] + } + ] + }, + { + "id": 6, + "name": "main-flow", + "children": [ + { + "id": 7, + "name": "dashboard", + "children": [ + { + "id": 8, + "name": "stats-widget", + "children": [] + }, + { + "id": 9, + "name": "recent-activity", + "children": [] + } + ] + } + ] + }, + { + "id": 10, + "name": "background-tasks", + "children": [ + { + "id": 11, + "name": "sync-service", + "children": [ + { + "id": 12, + "name": "profile-sync", + "children": [] + }, + { + "id": 13, + "name": "preferences-sync", + "children": [] + } + ] + }, + { + "id": 14, + "name": "notification-service", + "children": [ + { + "id": 15, + "name": "push-handler", + "children": [] + } + ] + } + ] + }, + { + "id": 16, + "name": "settings-flow", + "children": [ + { + "id": 17, + "name": "settings-screen", + "children": [ + { + "id": 18, + "name": "profile-settings", + "children": [] + }, + { + "id": 19, + "name": "notification-settings", + "children": [] + } + ] + } + ] + } + ] +} +] diff --git a/workflow-trace-viewer/src/jvmMain/resources/workflow-300.json b/workflow-trace-viewer/src/jvmMain/resources/workflow-300.json new file mode 100644 index 000000000..2f9a2df11 --- /dev/null +++ b/workflow-trace-viewer/src/jvmMain/resources/workflow-300.json @@ -0,0 +1,2600 @@ +[ +{ + "id": 1, + "name": "root", + "children": [ + { + "id": 2, + "name": "comment-proxy", + "children": [ + { + "id": 11, + "name": "transformer-collector", + "children": [ + { + "id": 14, + "name": "backup-tracker", + "children": [ + { + "id": 32, + "name": "schedule-form", + "children": [ + { + "id": 66, + "name": "download-aggregator", + "children": [] + }, + { + "id": 67, + "name": "search-handler", + "children": [] + }, + { + "id": 68, + "name": "share-dispatcher", + "children": [] + }, + { + "id": 69, + "name": "profile-factory", + "children": [ + { + "id": 71, + "name": "schedule-tracker", + "children": [] + }, + { + "id": 72, + "name": "data-adapter", + "children": [] + }, + { + "id": 73, + "name": "export-server", + "children": [] + }, + { + "id": 74, + "name": "processor-builder", + "children": [] + } + ] + }, + { + "id": 70, + "name": "signup-controller", + "children": [] + } + ] + }, + { + "id": 33, + "name": "schedule-transformer", + "children": [ + { + "id": 113, + "name": "cart-dispatcher", + "children": [] + }, + { + "id": 114, + "name": "cache-validator", + "children": [] + }, + { + "id": 115, + "name": "admin-server", + "children": [] + }, + { + "id": 116, + "name": "notification-client", + "children": [] + }, + { + "id": 117, + "name": "document-formatter", + "children": [] + } + ] + }, + { + "id": 109, + "name": "utils", + "children": [ + { + "id": 186, + "name": "migration-dispatcher", + "children": [] + }, + { + "id": 187, + "name": "audio-gateway", + "children": [] + }, + { + "id": 335, + "name": "export-form", + "children": [] + }, + { + "id": 336, + "name": "schedule-reporter", + "children": [] + }, + { + "id": 337, + "name": "image-collector", + "children": [] + } + ] + }, + { + "id": 110, + "name": "admin-manager", + "children": [ + { + "id": 124, + "name": "video-sorter", + "children": [] + }, + { + "id": 125, + "name": "config", + "children": [] + }, + { + "id": 156, + "name": "search-encoder", + "children": [ + { + "id": 159, + "name": "storage-logger", + "children": [] + }, + { + "id": 160, + "name": "video-manager", + "children": [] + } + ] + }, + { + "id": 157, + "name": "share-formatter", + "children": [] + }, + { + "id": 175, + "name": "import-reporter", + "children": [ + { + "id": 181, + "name": "api-screen", + "children": [] + }, + { + "id": 182, + "name": "task-client", + "children": [] + }, + { + "id": 183, + "name": "storage-transformer", + "children": [] + }, + { + "id": 184, + "name": "order-service", + "children": [] + }, + { + "id": 185, + "name": "service-component", + "children": [] + } + ] + } + ] + }, + { + "id": 197, + "name": "processor-reporter", + "children": [] + } + ] + }, + { + "id": 15, + "name": "user-client", + "children": [ + { + "id": 47, + "name": "download-parser", + "children": [] + }, + { + "id": 48, + "name": "migration-handler", + "children": [] + }, + { + "id": 49, + "name": "filter-sorter", + "children": [] + }, + { + "id": 198, + "name": "notification-sorter", + "children": [] + }, + { + "id": 256, + "name": "message-formatter", + "children": [] + }, + { + "id": 257, + "name": "message-client", + "children": [] + }, + { + "id": 258, + "name": "storage-gateway", + "children": [] + }, + { + "id": 259, + "name": "schedule-client", + "children": [] + } + ] + }, + { + "id": 16, + "name": "processor-optimizer", + "children": [ + { + "id": 19, + "name": "message-reporter", + "children": [ + { + "id": 283, + "name": "schedule-collector", + "children": [] + }, + { + "id": 284, + "name": "review-scheduler", + "children": [] + }, + { + "id": 285, + "name": "sync-filter", + "children": [] + }, + { + "id": 286, + "name": "workspace-validator", + "children": [] + }, + { + "id": 338, + "name": "signup-logger", + "children": [ + { + "id": 482, + "name": "admin-monitor", + "children": [] + }, + { + "id": 483, + "name": "storage-collector", + "children": [] + } + ] + }, + { + "id": 339, + "name": "download-flow", + "children": [ + { + "id": 355, + "name": "order-processor", + "children": [] + }, + { + "id": 356, + "name": "import-client", + "children": [] + }, + { + "id": 357, + "name": "report-collector", + "children": [] + }, + { + "id": 358, + "name": "storage-client", + "children": [] + } + ] + } + ] + }, + { + "id": 20, + "name": "comment-service", + "children": [ + { + "id": 34, + "name": "report-monitor", + "children": [ + { + "id": 84, + "name": "worker-widget", + "children": [ + { + "id": 384, + "name": "migration-service", + "children": [] + }, + { + "id": 385, + "name": "validator-reporter", + "children": [] + }, + { + "id": 386, + "name": "api-server", + "children": [] + }, + { + "id": 387, + "name": "data-logger", + "children": [] + }, + { + "id": 388, + "name": "document-screen", + "children": [] + } + ] + }, + { + "id": 85, + "name": "constants", + "children": [ + { + "id": 131, + "name": "sync-controller", + "children": [] + }, + { + "id": 174, + "name": "message-flow", + "children": [] + }, + { + "id": 214, + "name": "image-tracker", + "children": [ + { + "id": 289, + "name": "project-server", + "children": [ + { + "id": 321, + "name": "filter-manager", + "children": [] + }, + { + "id": 322, + "name": "refresh", + "children": [] + }, + { + "id": 323, + "name": "service-controller", + "children": [ + { + "id": 334, + "name": "audio-scheduler", + "children": [] + } + ] + }, + { + "id": 324, + "name": "upload-controller", + "children": [] + }, + { + "id": 325, + "name": "login-analyzer", + "children": [] + } + ] + }, + { + "id": 366, + "name": "user-parser", + "children": [] + }, + { + "id": 367, + "name": "settings-handler", + "children": [] + }, + { + "id": 368, + "name": "database-component", + "children": [] + }, + { + "id": 369, + "name": "login-filter", + "children": [] + }, + { + "id": 370, + "name": "workspace-dispatcher", + "children": [] + } + ] + }, + { + "id": 215, + "name": "worker-scheduler", + "children": [ + { + "id": 265, + "name": "task-gateway", + "children": [] + }, + { + "id": 266, + "name": "schedule-processor", + "children": [ + { + "id": 290, + "name": "upload-server", + "children": [ + { + "id": 307, + "name": "database-validator", + "children": [] + }, + { + "id": 308, + "name": "signup-aggregator", + "children": [] + }, + { + "id": 309, + "name": "share-optimizer", + "children": [] + }, + { + "id": 315, + "name": "order-reporter", + "children": [] + }, + { + "id": 316, + "name": "auth-service", + "children": [ + { + "id": 390, + "name": "document-factory", + "children": [ + { + "id": 494, + "name": "admin-aggregator", + "children": [] + }, + { + "id": 495, + "name": "task-scheduler", + "children": [] + } + ] + }, + { + "id": 391, + "name": "team-screen", + "children": [ + { + "id": 418, + "name": "backup-sorter", + "children": [ + { + "id": 449, + "name": "share-form", + "children": [] + }, + { + "id": 450, + "name": "user-monitor", + "children": [] + }, + { + "id": 488, + "name": "share-scheduler", + "children": [] + } + ] + }, + { + "id": 419, + "name": "backup-factory", + "children": [] + }, + { + "id": 420, + "name": "sync-optimizer", + "children": [] + }, + { + "id": 421, + "name": "video-server", + "children": [] + }, + { + "id": 422, + "name": "signup-validator", + "children": [] + } + ] + }, + { + "id": 392, + "name": "signup-transformer", + "children": [] + }, + { + "id": 393, + "name": "share-controller", + "children": [] + }, + { + "id": 433, + "name": "settings-client", + "children": [] + }, + { + "id": 434, + "name": "notification-controller", + "children": [] + }, + { + "id": 435, + "name": "workspace-collector", + "children": [] + }, + { + "id": 436, + "name": "processor-server", + "children": [] + }, + { + "id": 437, + "name": "report-transformer", + "children": [] + } + ] + } + ] + }, + { + "id": 291, + "name": "worker-client", + "children": [ + { + "id": 310, + "name": "analytics-adapter", + "children": [] + }, + { + "id": 311, + "name": "settings-tracker", + "children": [] + }, + { + "id": 312, + "name": "migration-processor", + "children": [] + }, + { + "id": 451, + "name": "validator-analyzer", + "children": [] + }, + { + "id": 452, + "name": "filter-collector", + "children": [] + }, + { + "id": 453, + "name": "export-collector", + "children": [] + }, + { + "id": 454, + "name": "team-adapter", + "children": [] + }, + { + "id": 455, + "name": "workspace-service", + "children": [] + } + ] + }, + { + "id": 410, + "name": "video-service", + "children": [] + }, + { + "id": 411, + "name": "analytics-builder", + "children": [] + }, + { + "id": 412, + "name": "workspace-proxy", + "children": [] + }, + { + "id": 413, + "name": "service-reporter", + "children": [] + } + ] + }, + { + "id": 267, + "name": "rating-parser", + "children": [] + }, + { + "id": 268, + "name": "cart-manager", + "children": [ + { + "id": 273, + "name": "team-component", + "children": [] + }, + { + "id": 274, + "name": "signup-tracker", + "children": [] + }, + { + "id": 275, + "name": "sync-dispatcher", + "children": [] + }, + { + "id": 276, + "name": "worker-processor", + "children": [] + }, + { + "id": 277, + "name": "auth-server", + "children": [] + } + ] + }, + { + "id": 269, + "name": "backup-service", + "children": [] + } + ] + }, + { + "id": 216, + "name": "login-builder", + "children": [] + }, + { + "id": 217, + "name": "login-formatter", + "children": [] + } + ] + }, + { + "id": 313, + "name": "import-dispatcher", + "children": [ + { + "id": 317, + "name": "transformer-monitor", + "children": [] + }, + { + "id": 318, + "name": "message-dispatcher", + "children": [] + }, + { + "id": 319, + "name": "dashboard-collector", + "children": [] + }, + { + "id": 320, + "name": "cache-processor", + "children": [] + } + ] + }, + { + "id": 314, + "name": "filter-widget", + "children": [ + { + "id": 428, + "name": "notification-optimizer", + "children": [] + }, + { + "id": 429, + "name": "migration-analyzer", + "children": [] + }, + { + "id": 430, + "name": "download-gateway", + "children": [] + }, + { + "id": 431, + "name": "workspace-widget", + "children": [] + }, + { + "id": 432, + "name": "image-parser", + "children": [] + } + ] + }, + { + "id": 461, + "name": "analytics-flow", + "children": [] + }, + { + "id": 462, + "name": "order-filter", + "children": [] + }, + { + "id": 463, + "name": "image-builder", + "children": [] + }, + { + "id": 464, + "name": "rating-proxy", + "children": [] + }, + { + "id": 465, + "name": "auth-gateway", + "children": [] + } + ] + }, + { + "id": 35, + "name": "comment-widget", + "children": [] + }, + { + "id": 36, + "name": "user-factory", + "children": [] + }, + { + "id": 86, + "name": "backup-screen", + "children": [ + { + "id": 234, + "name": "document-service", + "children": [] + }, + { + "id": 235, + "name": "cart-builder", + "children": [] + }, + { + "id": 236, + "name": "restore-gateway", + "children": [] + }, + { + "id": 237, + "name": "upload-analyzer", + "children": [] + }, + { + "id": 251, + "name": "analytics-analyzer", + "children": [] + }, + { + "id": 252, + "name": "comment-reporter", + "children": [] + }, + { + "id": 253, + "name": "storage-dispatcher", + "children": [] + }, + { + "id": 254, + "name": "report-client", + "children": [] + }, + { + "id": 255, + "name": "restore-factory", + "children": [] + } + ] + }, + { + "id": 87, + "name": "audio-client", + "children": [ + { + "id": 147, + "name": "restore-client", + "children": [] + }, + { + "id": 148, + "name": "auth-aggregator", + "children": [] + }, + { + "id": 149, + "name": "filter-parser", + "children": [] + }, + { + "id": 150, + "name": "migration-adapter", + "children": [] + }, + { + "id": 151, + "name": "team-scheduler", + "children": [ + { + "id": 152, + "name": "restore-builder", + "children": [] + }, + { + "id": 153, + "name": "filter-scheduler", + "children": [] + }, + { + "id": 154, + "name": "message-adapter", + "children": [] + }, + { + "id": 155, + "name": "storage-handler", + "children": [] + } + ] + } + ] + }, + { + "id": 88, + "name": "login-client", + "children": [] + }, + { + "id": 89, + "name": "payment-transformer", + "children": [] + } + ] + }, + { + "id": 21, + "name": "service-sorter", + "children": [ + { + "id": 118, + "name": "video-transformer", + "children": [] + }, + { + "id": 270, + "name": "comment-parser", + "children": [] + }, + { + "id": 271, + "name": "restore-form", + "children": [] + }, + { + "id": 272, + "name": "signup-manager", + "children": [] + }, + { + "id": 340, + "name": "schedule-component", + "children": [] + }, + { + "id": 341, + "name": "processor-formatter", + "children": [] + }, + { + "id": 342, + "name": "image-form", + "children": [ + { + "id": 346, + "name": "worker-sorter", + "children": [ + { + "id": 359, + "name": "share-flow", + "children": [] + }, + { + "id": 360, + "name": "admin-flow", + "children": [] + }, + { + "id": 361, + "name": "worker-validator", + "children": [] + } + ] + }, + { + "id": 347, + "name": "download-optimizer", + "children": [] + }, + { + "id": 348, + "name": "login-adapter", + "children": [] + }, + { + "id": 349, + "name": "audio-transformer", + "children": [] + } + ] + }, + { + "id": 343, + "name": "processor-factory", + "children": [] + }, + { + "id": 344, + "name": "dashboard-component", + "children": [ + { + "id": 350, + "name": "restore-scheduler", + "children": [] + }, + { + "id": 351, + "name": "data-aggregator", + "children": [] + }, + { + "id": 352, + "name": "user-service", + "children": [] + }, + { + "id": 353, + "name": "image-dispatcher", + "children": [] + }, + { + "id": 354, + "name": "dashboard-formatter", + "children": [] + } + ] + } + ] + }, + { + "id": 96, + "name": "login-optimizer", + "children": [] + }, + { + "id": 97, + "name": "api-sorter", + "children": [] + }, + { + "id": 98, + "name": "user-filter", + "children": [] + }, + { + "id": 99, + "name": "login-dispatcher", + "children": [] + } + ] + }, + { + "id": 17, + "name": "rating-controller", + "children": [ + { + "id": 30, + "name": "workspace-flow", + "children": [ + { + "id": 466, + "name": "api-formatter", + "children": [] + }, + { + "id": 467, + "name": "auth-tracker", + "children": [] + }, + { + "id": 468, + "name": "message-decoder", + "children": [] + }, + { + "id": 469, + "name": "audio-collector", + "children": [] + }, + { + "id": 470, + "name": "project-service", + "children": [] + } + ] + }, + { + "id": 31, + "name": "team-proxy", + "children": [ + { + "id": 80, + "name": "chat-component", + "children": [] + }, + { + "id": 81, + "name": "project-validator", + "children": [] + }, + { + "id": 82, + "name": "search-dispatcher", + "children": [] + }, + { + "id": 83, + "name": "signup-factory", + "children": [] + }, + { + "id": 239, + "name": "chat-client", + "children": [] + }, + { + "id": 240, + "name": "import-proxy", + "children": [] + } + ] + }, + { + "id": 61, + "name": "schedule-dispatcher", + "children": [] + }, + { + "id": 62, + "name": "document-filter", + "children": [] + }, + { + "id": 139, + "name": "login-handler", + "children": [ + { + "id": 158, + "name": "review-adapter", + "children": [] + } + ] + }, + { + "id": 140, + "name": "restore-adapter", + "children": [] + }, + { + "id": 141, + "name": "migration-tracker", + "children": [] + } + ] + }, + { + "id": 18, + "name": "validator-flow", + "children": [ + { + "id": 22, + "name": "login-reporter", + "children": [ + { + "id": 28, + "name": "api-controller", + "children": [ + { + "id": 104, + "name": "project-flow", + "children": [] + }, + { + "id": 105, + "name": "worker-filter", + "children": [] + }, + { + "id": 106, + "name": "comment-client", + "children": [] + }, + { + "id": 107, + "name": "download-widget", + "children": [] + }, + { + "id": 108, + "name": "login-decoder", + "children": [] + } + ] + }, + { + "id": 29, + "name": "transformer-server", + "children": [ + { + "id": 50, + "name": "cache-component", + "children": [] + }, + { + "id": 51, + "name": "worker-decoder", + "children": [] + }, + { + "id": 52, + "name": "video-component", + "children": [] + }, + { + "id": 53, + "name": "backup-transformer", + "children": [] + }, + { + "id": 111, + "name": "setup", + "children": [ + { + "id": 126, + "name": "project-handler", + "children": [] + }, + { + "id": 127, + "name": "admin-tracker", + "children": [] + }, + { + "id": 128, + "name": "validator-encoder", + "children": [] + }, + { + "id": 129, + "name": "task-builder", + "children": [] + }, + { + "id": 130, + "name": "project-sorter", + "children": [] + } + ] + }, + { + "id": 112, + "name": "backup-server", + "children": [ + { + "id": 219, + "name": "notification-parser", + "children": [] + }, + { + "id": 220, + "name": "profile-reporter", + "children": [] + }, + { + "id": 221, + "name": "storage-factory", + "children": [] + }, + { + "id": 222, + "name": "video-adapter", + "children": [] + }, + { + "id": 389, + "name": "workspace-factory", + "children": [ + { + "id": 423, + "name": "api-service", + "children": [] + } + ] + } + ] + } + ] + }, + { + "id": 91, + "name": "auth-encoder", + "children": [] + }, + { + "id": 119, + "name": "backup-handler", + "children": [] + }, + { + "id": 120, + "name": "dashboard-dispatcher", + "children": [] + } + ] + }, + { + "id": 23, + "name": "cart-client", + "children": [ + { + "id": 207, + "name": "search-sorter", + "children": [] + }, + { + "id": 208, + "name": "schedule-aggregator", + "children": [] + }, + { + "id": 209, + "name": "document-adapter", + "children": [] + } + ] + }, + { + "id": 24, + "name": "upload-tracker", + "children": [ + { + "id": 161, + "name": "message-component", + "children": [ + { + "id": 498, + "name": "api-manager", + "children": [] + }, + { + "id": 499, + "name": "auth-client", + "children": [] + }, + { + "id": 500, + "name": "storage-parser", + "children": [] + } + ] + }, + { + "id": 162, + "name": "cache-factory", + "children": [] + }, + { + "id": 163, + "name": "user-logger", + "children": [ + { + "id": 165, + "name": "search-monitor", + "children": [] + }, + { + "id": 166, + "name": "validator-screen", + "children": [] + }, + { + "id": 167, + "name": "comment-logger", + "children": [] + }, + { + "id": 190, + "name": "report-server", + "children": [ + { + "id": 247, + "name": "analytics-widget", + "children": [] + }, + { + "id": 248, + "name": "cache-flow", + "children": [] + }, + { + "id": 249, + "name": "analytics-optimizer", + "children": [] + }, + { + "id": 250, + "name": "report-service", + "children": [] + }, + { + "id": 362, + "name": "workspace-formatter", + "children": [ + { + "id": 403, + "name": "filter-validator", + "children": [] + }, + { + "id": 404, + "name": "cart-adapter", + "children": [] + }, + { + "id": 405, + "name": "api-reporter", + "children": [] + }, + { + "id": 406, + "name": "migration-scheduler", + "children": [] + }, + { + "id": 407, + "name": "review-flow", + "children": [] + } + ] + }, + { + "id": 363, + "name": "document-validator", + "children": [ + { + "id": 440, + "name": "storage-filter", + "children": [ + { + "id": 445, + "name": "cart-factory", + "children": [] + }, + { + "id": 446, + "name": "task-filter", + "children": [] + }, + { + "id": 447, + "name": "team-validator", + "children": [] + }, + { + "id": 448, + "name": "report-parser", + "children": [] + }, + { + "id": 476, + "name": "filter-form", + "children": [] + }, + { + "id": 477, + "name": "video-encoder", + "children": [] + } + ] + }, + { + "id": 441, + "name": "analytics-scheduler", + "children": [] + }, + { + "id": 442, + "name": "chat-optimizer", + "children": [] + }, + { + "id": 443, + "name": "validator-form", + "children": [ + { + "id": 496, + "name": "processor-component", + "children": [] + }, + { + "id": 497, + "name": "migration-reporter", + "children": [] + } + ] + }, + { + "id": 444, + "name": "dashboard-decoder", + "children": [] + } + ] + }, + { + "id": 364, + "name": "notification-form", + "children": [ + { + "id": 408, + "name": "order-controller", + "children": [] + }, + { + "id": 409, + "name": "backup-reporter", + "children": [] + } + ] + }, + { + "id": 365, + "name": "sync-monitor", + "children": [] + } + ] + }, + { + "id": 191, + "name": "share-sorter", + "children": [ + { + "id": 228, + "name": "migration-client", + "children": [] + }, + { + "id": 229, + "name": "profile-monitor", + "children": [] + }, + { + "id": 260, + "name": "payment-factory", + "children": [] + }, + { + "id": 471, + "name": "schedule-flow", + "children": [ + { + "id": 472, + "name": "upload-widget", + "children": [] + }, + { + "id": 473, + "name": "download-client", + "children": [] + }, + { + "id": 474, + "name": "payment-analyzer", + "children": [] + } + ] + } + ] + }, + { + "id": 192, + "name": "search-service", + "children": [ + { + "id": 295, + "name": "validator-component", + "children": [] + }, + { + "id": 296, + "name": "team-transformer", + "children": [] + }, + { + "id": 297, + "name": "database-aggregator", + "children": [] + }, + { + "id": 298, + "name": "profile-sorter", + "children": [] + }, + { + "id": 398, + "name": "report-manager", + "children": [] + }, + { + "id": 399, + "name": "review-widget", + "children": [] + }, + { + "id": 400, + "name": "message-widget", + "children": [] + }, + { + "id": 401, + "name": "user-encoder", + "children": [] + }, + { + "id": 402, + "name": "auth-formatter", + "children": [] + } + ] + }, + { + "id": 193, + "name": "worker-monitor", + "children": [] + } + ] + }, + { + "id": 164, + "name": "database-parser", + "children": [ + { + "id": 394, + "name": "worker-server", + "children": [] + }, + { + "id": 395, + "name": "transformer-decoder", + "children": [] + }, + { + "id": 396, + "name": "settings-processor", + "children": [] + }, + { + "id": 397, + "name": "document-controller", + "children": [] + } + ] + }, + { + "id": 168, + "name": "audio-validator", + "children": [] + }, + { + "id": 169, + "name": "transformer-client", + "children": [] + } + ] + }, + { + "id": 25, + "name": "export-aggregator", + "children": [ + { + "id": 218, + "name": "project-client", + "children": [] + } + ] + }, + { + "id": 26, + "name": "auth-handler", + "children": [ + { + "id": 223, + "name": "chat-decoder", + "children": [] + }, + { + "id": 224, + "name": "filter-decoder", + "children": [] + }, + { + "id": 225, + "name": "document-collector", + "children": [] + }, + { + "id": 226, + "name": "backup-proxy", + "children": [] + }, + { + "id": 227, + "name": "api-factory", + "children": [] + } + ] + }, + { + "id": 27, + "name": "migration-optimizer", + "children": [ + { + "id": 54, + "name": "signup-screen", + "children": [] + }, + { + "id": 55, + "name": "settings-flow", + "children": [] + }, + { + "id": 56, + "name": "task-service", + "children": [] + }, + { + "id": 57, + "name": "backup-component", + "children": [] + }, + { + "id": 58, + "name": "order-builder", + "children": [] + } + ] + } + ] + } + ] + }, + { + "id": 60, + "name": "cart-component", + "children": [ + { + "id": 75, + "name": "backup-processor", + "children": [] + }, + { + "id": 76, + "name": "import-filter", + "children": [] + }, + { + "id": 77, + "name": "database-proxy", + "children": [] + }, + { + "id": 78, + "name": "share-service", + "children": [] + }, + { + "id": 79, + "name": "dashboard-screen", + "children": [] + } + ] + }, + { + "id": 241, + "name": "dashboard-manager", + "children": [ + { + "id": 305, + "name": "database-client", + "children": [] + }, + { + "id": 306, + "name": "signup-proxy", + "children": [] + }, + { + "id": 371, + "name": "signup-sorter", + "children": [] + }, + { + "id": 372, + "name": "review-dispatcher", + "children": [] + }, + { + "id": 377, + "name": "api-adapter", + "children": [] + }, + { + "id": 378, + "name": "message-tracker", + "children": [] + }, + { + "id": 379, + "name": "upload-gateway", + "children": [] + }, + { + "id": 380, + "name": "login-validator", + "children": [] + }, + { + "id": 381, + "name": "sync-form", + "children": [] + } + ] + }, + { + "id": 242, + "name": "signup-builder", + "children": [ + { + "id": 326, + "name": "share-reporter", + "children": [] + }, + { + "id": 327, + "name": "filter-client", + "children": [ + { + "id": 331, + "name": "cache-proxy", + "children": [] + }, + { + "id": 332, + "name": "export-screen", + "children": [] + }, + { + "id": 333, + "name": "processor-widget", + "children": [] + } + ] + }, + { + "id": 328, + "name": "backup-formatter", + "children": [] + }, + { + "id": 329, + "name": "admin-builder", + "children": [] + }, + { + "id": 330, + "name": "payment-collector", + "children": [] + } + ] + }, + { + "id": 243, + "name": "calendar-manager", + "children": [ + { + "id": 478, + "name": "review-gateway", + "children": [] + }, + { + "id": 479, + "name": "import-tracker", + "children": [] + }, + { + "id": 480, + "name": "rating-builder", + "children": [] + }, + { + "id": 481, + "name": "sync-screen", + "children": [] + } + ] + } + ] + }, + { + "id": 3, + "name": "user-collector", + "children": [ + { + "id": 44, + "name": "cart-formatter", + "children": [] + }, + { + "id": 45, + "name": "user-manager", + "children": [] + }, + { + "id": 46, + "name": "database-reporter", + "children": [] + }, + { + "id": 90, + "name": "document-gateway", + "children": [] + }, + { + "id": 292, + "name": "auth-collector", + "children": [] + }, + { + "id": 293, + "name": "image-handler", + "children": [] + }, + { + "id": 294, + "name": "export-optimizer", + "children": [] + } + ] + }, + { + "id": 4, + "name": "filter-logger", + "children": [ + { + "id": 12, + "name": "notification-decoder", + "children": [ + { + "id": 63, + "name": "download-controller", + "children": [] + }, + { + "id": 64, + "name": "database-widget", + "children": [] + }, + { + "id": 205, + "name": "auth-transformer", + "children": [] + }, + { + "id": 206, + "name": "task-logger", + "children": [] + }, + { + "id": 261, + "name": "database-filter", + "children": [] + }, + { + "id": 262, + "name": "review-filter", + "children": [] + }, + { + "id": 263, + "name": "audio-server", + "children": [] + }, + { + "id": 264, + "name": "notification-factory", + "children": [] + } + ] + }, + { + "id": 13, + "name": "service-monitor", + "children": [ + { + "id": 37, + "name": "schedule-monitor", + "children": [] + }, + { + "id": 38, + "name": "download-monitor", + "children": [] + }, + { + "id": 39, + "name": "message-manager", + "children": [] + }, + { + "id": 40, + "name": "rating-filter", + "children": [] + }, + { + "id": 194, + "name": "search-analyzer", + "children": [] + }, + { + "id": 195, + "name": "message-filter", + "children": [] + }, + { + "id": 196, + "name": "order-monitor", + "children": [] + } + ] + }, + { + "id": 202, + "name": "video-client", + "children": [] + }, + { + "id": 203, + "name": "chat-transformer", + "children": [] + }, + { + "id": 204, + "name": "search-gateway", + "children": [] + } + ] + }, + { + "id": 5, + "name": "filter-monitor", + "children": [ + { + "id": 41, + "name": "api-optimizer", + "children": [] + }, + { + "id": 42, + "name": "settings-formatter", + "children": [] + }, + { + "id": 43, + "name": "import-logger", + "children": [] + }, + { + "id": 65, + "name": "team-logger", + "children": [] + }, + { + "id": 373, + "name": "stop", + "children": [] + }, + { + "id": 374, + "name": "worker-formatter", + "children": [ + { + "id": 382, + "name": "document-builder", + "children": [] + }, + { + "id": 383, + "name": "share-monitor", + "children": [] + } + ] + }, + { + "id": 375, + "name": "order-decoder", + "children": [] + }, + { + "id": 376, + "name": "download-transformer", + "children": [] + } + ] + }, + { + "id": 6, + "name": "transformer-dispatcher", + "children": [ + { + "id": 132, + "name": "export-dispatcher", + "children": [] + }, + { + "id": 133, + "name": "task-monitor", + "children": [] + }, + { + "id": 134, + "name": "profile-server", + "children": [] + }, + { + "id": 136, + "name": "cart-widget", + "children": [ + { + "id": 170, + "name": "upload-manager", + "children": [] + }, + { + "id": 171, + "name": "payment-validator", + "children": [] + }, + { + "id": 244, + "name": "filter-gateway", + "children": [] + }, + { + "id": 245, + "name": "task-component", + "children": [] + }, + { + "id": 246, + "name": "api-proxy", + "children": [] + } + ] + }, + { + "id": 137, + "name": "review-component", + "children": [ + { + "id": 230, + "name": "restore-collector", + "children": [] + }, + { + "id": 231, + "name": "cache-analyzer", + "children": [] + }, + { + "id": 232, + "name": "worker-parser", + "children": [] + }, + { + "id": 233, + "name": "import-monitor", + "children": [] + }, + { + "id": 438, + "name": "cart-service", + "children": [] + }, + { + "id": 439, + "name": "order-transformer", + "children": [] + } + ] + } + ] + }, + { + "id": 7, + "name": "dashboard-encoder", + "children": [ + { + "id": 8, + "name": "validator-validator", + "children": [ + { + "id": 138, + "name": "signup-optimizer", + "children": [ + { + "id": 142, + "name": "notification-manager", + "children": [] + }, + { + "id": 143, + "name": "profile-collector", + "children": [] + }, + { + "id": 144, + "name": "project-encoder", + "children": [] + }, + { + "id": 145, + "name": "admin-client", + "children": [] + }, + { + "id": 146, + "name": "validator-controller", + "children": [] + } + ] + }, + { + "id": 176, + "name": "admin-filter", + "children": [] + }, + { + "id": 177, + "name": "service-tracker", + "children": [] + }, + { + "id": 178, + "name": "order-parser", + "children": [] + }, + { + "id": 179, + "name": "cache-service", + "children": [] + }, + { + "id": 180, + "name": "auth-dispatcher", + "children": [] + } + ] + }, + { + "id": 9, + "name": "export-proxy", + "children": [ + { + "id": 135, + "name": "database-screen", + "children": [ + { + "id": 210, + "name": "migration-server", + "children": [] + }, + { + "id": 211, + "name": "video-handler", + "children": [] + }, + { + "id": 212, + "name": "data-manager", + "children": [] + }, + { + "id": 213, + "name": "analytics-processor", + "children": [] + }, + { + "id": 303, + "name": "search-formatter", + "children": [] + }, + { + "id": 304, + "name": "analytics-encoder", + "children": [] + } + ] + }, + { + "id": 188, + "name": "user-validator", + "children": [ + { + "id": 345, + "name": "service-manager", + "children": [] + }, + { + "id": 414, + "name": "sync-formatter", + "children": [ + { + "id": 484, + "name": "payment-proxy", + "children": [] + }, + { + "id": 485, + "name": "profile-adapter", + "children": [] + }, + { + "id": 486, + "name": "team-formatter", + "children": [] + }, + { + "id": 487, + "name": "upload-logger", + "children": [] + } + ] + }, + { + "id": 415, + "name": "calendar-builder", + "children": [] + }, + { + "id": 416, + "name": "api-handler", + "children": [ + { + "id": 489, + "name": "task-formatter", + "children": [] + }, + { + "id": 490, + "name": "signup-gateway", + "children": [] + }, + { + "id": 491, + "name": "validator-optimizer", + "children": [] + }, + { + "id": 492, + "name": "admin-dispatcher", + "children": [] + }, + { + "id": 493, + "name": "restore-filter", + "children": [] + } + ] + }, + { + "id": 417, + "name": "task-decoder", + "children": [ + { + "id": 424, + "name": "pause", + "children": [] + }, + { + "id": 425, + "name": "search-form", + "children": [ + { + "id": 427, + "name": "document-widget", + "children": [] + } + ] + }, + { + "id": 426, + "name": "signup-decoder", + "children": [] + }, + { + "id": 475, + "name": "worker-analyzer", + "children": [] + } + ] + } + ] + }, + { + "id": 189, + "name": "download-handler", + "children": [ + { + "id": 456, + "name": "data-dispatcher", + "children": [] + }, + { + "id": 457, + "name": "profile-encoder", + "children": [] + }, + { + "id": 458, + "name": "document-processor", + "children": [] + }, + { + "id": 459, + "name": "search-collector", + "children": [] + }, + { + "id": 460, + "name": "data-collector", + "children": [] + } + ] + }, + { + "id": 238, + "name": "team-widget", + "children": [ + { + "id": 278, + "name": "download-collector", + "children": [] + }, + { + "id": 279, + "name": "task-screen", + "children": [] + }, + { + "id": 280, + "name": "notification-builder", + "children": [ + { + "id": 287, + "name": "upload-flow", + "children": [] + }, + { + "id": 288, + "name": "common", + "children": [] + } + ] + }, + { + "id": 281, + "name": "database-encoder", + "children": [] + }, + { + "id": 282, + "name": "signup-analyzer", + "children": [] + } + ] + }, + { + "id": 299, + "name": "message-scheduler", + "children": [] + }, + { + "id": 300, + "name": "payment-optimizer", + "children": [] + }, + { + "id": 301, + "name": "calendar-aggregator", + "children": [] + }, + { + "id": 302, + "name": "export-encoder", + "children": [] + } + ] + }, + { + "id": 10, + "name": "database-dispatcher", + "children": [ + { + "id": 121, + "name": "export-manager", + "children": [] + }, + { + "id": 122, + "name": "database-manager", + "children": [] + }, + { + "id": 123, + "name": "report-logger", + "children": [] + }, + { + "id": 172, + "name": "report-decoder", + "children": [] + }, + { + "id": 173, + "name": "user-gateway", + "children": [] + } + ] + }, + { + "id": 59, + "name": "api-monitor", + "children": [ + { + "id": 100, + "name": "dashboard-builder", + "children": [] + }, + { + "id": 101, + "name": "chat-server", + "children": [] + }, + { + "id": 102, + "name": "settings-aggregator", + "children": [] + }, + { + "id": 103, + "name": "review-decoder", + "children": [] + }, + { + "id": 199, + "name": "cart-screen", + "children": [] + }, + { + "id": 200, + "name": "comment-handler", + "children": [] + }, + { + "id": 201, + "name": "project-widget", + "children": [] + } + ] + }, + { + "id": 92, + "name": "audio-aggregator", + "children": [] + }, + { + "id": 93, + "name": "backup-analyzer", + "children": [] + }, + { + "id": 94, + "name": "cache-client", + "children": [] + }, + { + "id": 95, + "name": "audio-service", + "children": [] + } + ] + } + ] +} +] diff --git a/workflow-trace-viewer/src/jvmMain/resources/workflow-traces.json b/workflow-trace-viewer/src/jvmMain/resources/workflow-traces.json new file mode 100644 index 000000000..4c1b0b77b --- /dev/null +++ b/workflow-trace-viewer/src/jvmMain/resources/workflow-traces.json @@ -0,0 +1,2102 @@ +[ + { + "id": 1, + "name": "root", + "children": [ + { + "id": 2, + "name": "auth-flow", + "children": [ + { + "id": 3, + "name": "login-screen", + "children": [ + { + "id": 4, + "name": "login-form", + "children": [] + }, + { + "id": 5, + "name": "social-login", + "children": [] + } + ] + } + ] + }, + { + "id": 6, + "name": "main-flow", + "children": [ + { + "id": 7, + "name": "dashboard", + "children": [ + { + "id": 8, + "name": "stats-widget", + "children": [] + }, + { + "id": 9, + "name": "recent-activity", + "children": [] + } + ] + } + ] + }, + { + "id": 10, + "name": "background-tasks", + "children": [ + { + "id": 11, + "name": "sync-service", + "children": [ + { + "id": 12, + "name": "profile-sync", + "children": [] + }, + { + "id": 13, + "name": "preferences-sync", + "children": [] + } + ] + }, + { + "id": 14, + "name": "notification-service", + "children": [ + { + "id": 15, + "name": "push-handler", + "children": [] + } + ] + } + ] + }, + { + "id": 16, + "name": "settings-flow", + "children": [ + { + "id": 17, + "name": "settings-screen", + "children": [ + { + "id": 18, + "name": "profile-settings", + "children": [] + }, + { + "id": 19, + "name": "notification-settings", + "children": [] + } + ] + } + ] + } + ] + }, + { + "id": 20, + "name": "app-root", + "children": [ + { + "id": 21, + "name": "user-flow", + "children": [ + { + "id": 22, + "name": "signup-screen", + "children": [ + { + "id": 23, + "name": "registration-form", + "children": [] + }, + { + "id": 24, + "name": "oauth-signup", + "children": [] + } + ] + } + ] + }, + { + "id": 25, + "name": "content-flow", + "children": [ + { + "id": 26, + "name": "home-screen", + "children": [ + { + "id": 27, + "name": "featured-content", + "children": [] + }, + { + "id": 28, + "name": "user-feed", + "children": [] + } + ] + } + ] + }, + { + "id": 29, + "name": "system-tasks", + "children": [ + { + "id": 30, + "name": "data-service", + "children": [ + { + "id": 31, + "name": "content-sync", + "children": [] + }, + { + "id": 32, + "name": "cache-manager", + "children": [] + } + ] + }, + { + "id": 33, + "name": "analytics-service", + "children": [ + { + "id": 34, + "name": "event-tracker", + "children": [] + } + ] + } + ] + }, + { + "id": 35, + "name": "preferences-flow", + "children": [ + { + "id": 36, + "name": "preferences-screen", + "children": [ + { + "id": 37, + "name": "account-settings", + "children": [] + }, + { + "id": 38, + "name": "privacy-settings", + "children": [] + } + ] + } + ] + } + ] + }, + { + "id": 39, + "name": "system-root", + "children": [ + { + "id": 40, + "name": "security-flow", + "children": [ + { + "id": 41, + "name": "auth-screen", + "children": [ + { + "id": 42, + "name": "biometric-auth", + "children": [] + }, + { + "id": 43, + "name": "pin-verification", + "children": [] + } + ] + } + ] + }, + { + "id": 44, + "name": "navigation-flow", + "children": [ + { + "id": 45, + "name": "tab-navigator", + "children": [ + { + "id": 46, + "name": "home-tab", + "children": [] + }, + { + "id": 47, + "name": "search-tab", + "children": [] + } + ] + } + ] + }, + { + "id": 48, + "name": "core-services", + "children": [ + { + "id": 49, + "name": "network-service", + "children": [ + { + "id": 50, + "name": "api-client", + "children": [] + }, + { + "id": 51, + "name": "offline-handler", + "children": [] + } + ] + }, + { + "id": 52, + "name": "storage-service", + "children": [ + { + "id": 53, + "name": "database-manager", + "children": [] + } + ] + } + ] + }, + { + "id": 54, + "name": "admin-flow", + "children": [ + { + "id": 55, + "name": "admin-panel", + "children": [ + { + "id": 56, + "name": "user-management", + "children": [] + }, + { + "id": 57, + "name": "system-monitoring", + "children": [] + } + ] + } + ] + } + ] + }, + { + "id": 58, + "name": "commerce-root", + "children": [ + { + "id": 59, + "name": "payment-flow", + "children": [ + { + "id": 60, + "name": "checkout-screen", + "children": [ + { + "id": 61, + "name": "payment-form", + "children": [] + }, + { + "id": 62, + "name": "card-scanner", + "children": [] + } + ] + } + ] + }, + { + "id": 63, + "name": "catalog-flow", + "children": [ + { + "id": 64, + "name": "product-list", + "children": [ + { + "id": 65, + "name": "product-card", + "children": [] + }, + { + "id": 66, + "name": "filter-panel", + "children": [] + } + ] + } + ] + }, + { + "id": 67, + "name": "order-tasks", + "children": [ + { + "id": 68, + "name": "fulfillment-service", + "children": [ + { + "id": 69, + "name": "inventory-check", + "children": [] + }, + { + "id": 70, + "name": "shipping-calc", + "children": [] + } + ] + }, + { + "id": 71, + "name": "payment-service", + "children": [ + { + "id": 72, + "name": "transaction-processor", + "children": [] + } + ] + } + ] + }, + { + "id": 73, + "name": "merchant-flow", + "children": [ + { + "id": 74, + "name": "seller-dashboard", + "children": [ + { + "id": 75, + "name": "sales-analytics", + "children": [] + }, + { + "id": 76, + "name": "inventory-manager", + "children": [] + } + ] + } + ] + } + ] + }, + { + "id": 77, + "name": "social-root", + "children": [ + { + "id": 78, + "name": "messaging-flow", + "children": [ + { + "id": 79, + "name": "chat-screen", + "children": [ + { + "id": 80, + "name": "message-input", + "children": [] + }, + { + "id": 81, + "name": "media-picker", + "children": [] + } + ] + } + ] + }, + { + "id": 82, + "name": "social-flow", + "children": [ + { + "id": 83, + "name": "timeline-screen", + "children": [ + { + "id": 84, + "name": "post-composer", + "children": [] + }, + { + "id": 85, + "name": "story-viewer", + "children": [] + } + ] + } + ] + }, + { + "id": 86, + "name": "connection-tasks", + "children": [ + { + "id": 87, + "name": "friend-service", + "children": [ + { + "id": 88, + "name": "contact-sync", + "children": [] + }, + { + "id": 89, + "name": "suggestion-engine", + "children": [] + } + ] + }, + { + "id": 90, + "name": "activity-service", + "children": [ + { + "id": 91, + "name": "feed-generator", + "children": [] + } + ] + } + ] + }, + { + "id": 92, + "name": "privacy-flow", + "children": [ + { + "id": 93, + "name": "privacy-screen", + "children": [ + { + "id": 94, + "name": "visibility-controls", + "children": [] + }, + { + "id": 95, + "name": "block-manager", + "children": [] + } + ] + } + ] + } + ] + }, + { + "id": 96, + "name": "media-root", + "children": [ + { + "id": 97, + "name": "streaming-flow", + "children": [ + { + "id": 98, + "name": "player-screen", + "children": [ + { + "id": 99, + "name": "video-player", + "children": [] + }, + { + "id": 100, + "name": "audio-controls", + "children": [] + } + ] + } + ] + }, + { + "id": 101, + "name": "library-flow", + "children": [ + { + "id": 102, + "name": "media-browser", + "children": [ + { + "id": 103, + "name": "playlist-view", + "children": [] + }, + { + "id": 104, + "name": "search-results", + "children": [] + } + ] + } + ] + }, + { + "id": 105, + "name": "processing-tasks", + "children": [ + { + "id": 106, + "name": "encoding-service", + "children": [ + { + "id": 107, + "name": "video-encoder", + "children": [] + }, + { + "id": 108, + "name": "thumbnail-generator", + "children": [] + } + ] + }, + { + "id": 109, + "name": "cdn-service", + "children": [ + { + "id": 110, + "name": "content-distributor", + "children": [] + } + ] + } + ] + }, + { + "id": 111, + "name": "creator-flow", + "children": [ + { + "id": 112, + "name": "upload-screen", + "children": [ + { + "id": 113, + "name": "file-uploader", + "children": [] + }, + { + "id": 114, + "name": "metadata-editor", + "children": [] + } + ] + } + ] + } + ] + }, + { + "id": 115, + "name": "finance-root", + "children": [ + { + "id": 116, + "name": "banking-flow", + "children": [ + { + "id": 117, + "name": "account-screen", + "children": [ + { + "id": 118, + "name": "balance-widget", + "children": [] + }, + { + "id": 119, + "name": "transaction-list", + "children": [] + } + ] + } + ] + }, + { + "id": 120, + "name": "transfer-flow", + "children": [ + { + "id": 121, + "name": "send-money-screen", + "children": [ + { + "id": 122, + "name": "recipient-selector", + "children": [] + }, + { + "id": 123, + "name": "amount-input", + "children": [] + } + ] + } + ] + }, + { + "id": 124, + "name": "financial-tasks", + "children": [ + { + "id": 125, + "name": "fraud-service", + "children": [ + { + "id": 126, + "name": "risk-analyzer", + "children": [] + }, + { + "id": 127, + "name": "pattern-detector", + "children": [] + } + ] + }, + { + "id": 128, + "name": "compliance-service", + "children": [ + { + "id": 129, + "name": "kyc-validator", + "children": [] + } + ] + } + ] + }, + { + "id": 130, + "name": "investment-flow", + "children": [ + { + "id": 131, + "name": "portfolio-screen", + "children": [ + { + "id": 132, + "name": "asset-overview", + "children": [] + }, + { + "id": 133, + "name": "performance-chart", + "children": [] + } + ] + } + ] + } + ] + }, + { + "id": 134, + "name": "health-root", + "children": [ + { + "id": 135, + "name": "tracking-flow", + "children": [ + { + "id": 136, + "name": "activity-screen", + "children": [ + { + "id": 137, + "name": "step-counter", + "children": [] + }, + { + "id": 138, + "name": "workout-tracker", + "children": [] + } + ] + } + ] + }, + { + "id": 139, + "name": "wellness-flow", + "children": [ + { + "id": 140, + "name": "health-dashboard", + "children": [ + { + "id": 141, + "name": "vital-signs", + "children": [] + }, + { + "id": 142, + "name": "medication-reminder", + "children": [] + } + ] + } + ] + }, + { + "id": 143, + "name": "monitoring-tasks", + "children": [ + { + "id": 144, + "name": "sensor-service", + "children": [ + { + "id": 145, + "name": "heart-rate-monitor", + "children": [] + }, + { + "id": 146, + "name": "sleep-tracker", + "children": [] + } + ] + }, + { + "id": 147, + "name": "analysis-service", + "children": [ + { + "id": 148, + "name": "trend-analyzer", + "children": [] + } + ] + } + ] + }, + { + "id": 149, + "name": "consultation-flow", + "children": [ + { + "id": 150, + "name": "telemedicine-screen", + "children": [ + { + "id": 151, + "name": "video-call", + "children": [] + }, + { + "id": 152, + "name": "symptom-checker", + "children": [] + } + ] + } + ] + } + ] + }, + { + "id": 153, + "name": "education-root", + "children": [ + { + "id": 154, + "name": "learning-flow", + "children": [ + { + "id": 155, + "name": "course-screen", + "children": [ + { + "id": 156, + "name": "video-lesson", + "children": [] + }, + { + "id": 157, + "name": "quiz-component", + "children": [] + } + ] + } + ] + }, + { + "id": 158, + "name": "progress-flow", + "children": [ + { + "id": 159, + "name": "achievement-screen", + "children": [ + { + "id": 160, + "name": "badge-display", + "children": [] + }, + { + "id": 161, + "name": "progress-tracker", + "children": [] + } + ] + } + ] + }, + { + "id": 162, + "name": "assessment-tasks", + "children": [ + { + "id": 163, + "name": "grading-service", + "children": [ + { + "id": 164, + "name": "auto-grader", + "children": [] + }, + { + "id": 165, + "name": "feedback-generator", + "children": [] + } + ] + }, + { + "id": 166, + "name": "analytics-service", + "children": [ + { + "id": 167, + "name": "learning-analyzer", + "children": [] + } + ] + } + ] + }, + { + "id": 168, + "name": "collaboration-flow", + "children": [ + { + "id": 169, + "name": "study-group-screen", + "children": [ + { + "id": 170, + "name": "whiteboard-tool", + "children": [] + }, + { + "id": 171, + "name": "discussion-forum", + "children": [] + } + ] + } + ] + } + ] + }, + { + "id": 172, + "name": "travel-root", + "children": [ + { + "id": 173, + "name": "booking-flow", + "children": [ + { + "id": 174, + "name": "search-screen", + "children": [ + { + "id": 175, + "name": "flight-finder", + "children": [] + }, + { + "id": 176, + "name": "hotel-search", + "children": [] + } + ] + } + ] + }, + { + "id": 177, + "name": "itinerary-flow", + "children": [ + { + "id": 178, + "name": "trip-planner", + "children": [ + { + "id": 179, + "name": "schedule-builder", + "children": [] + }, + { + "id": 180, + "name": "map-integration", + "children": [] + } + ] + } + ] + }, + { + "id": 181, + "name": "travel-tasks", + "children": [ + { + "id": 182, + "name": "booking-service", + "children": [ + { + "id": 183, + "name": "reservation-manager", + "children": [] + }, + { + "id": 184, + "name": "price-tracker", + "children": [] + } + ] + }, + { + "id": 185, + "name": "notification-service", + "children": [ + { + "id": 186, + "name": "flight-alerts", + "children": [] + } + ] + } + ] + }, + { + "id": 187, + "name": "companion-flow", + "children": [ + { + "id": 188, + "name": "travel-guide", + "children": [ + { + "id": 189, + "name": "local-recommendations", + "children": [] + }, + { + "id": 190, + "name": "weather-widget", + "children": [] + } + ] + } + ] + } + ] + }, + { + "id": 1, + "name": "root", + "children": [ + { + "id": 2, + "name": "auth-flow", + "children": [ + { + "id": 3, + "name": "login-screen", + "children": [ + { + "id": 4, + "name": "login-form", + "children": [] + }, + { + "id": 5, + "name": "social-login", + "children": [] + } + ] + } + ] + }, + { + "id": 6, + "name": "main-flow", + "children": [ + { + "id": 7, + "name": "dashboard", + "children": [ + { + "id": 8, + "name": "stats-widget", + "children": [] + }, + { + "id": 9, + "name": "recent-activity", + "children": [] + } + ] + } + ] + }, + { + "id": 10, + "name": "background-tasks", + "children": [ + { + "id": 11, + "name": "sync-service", + "children": [ + { + "id": 12, + "name": "profile-sync", + "children": [] + }, + { + "id": 13, + "name": "preferences-sync", + "children": [] + } + ] + }, + { + "id": 14, + "name": "notification-service", + "children": [ + { + "id": 15, + "name": "push-handler", + "children": [] + } + ] + } + ] + }, + { + "id": 16, + "name": "settings-flow", + "children": [ + { + "id": 17, + "name": "settings-screen", + "children": [ + { + "id": 18, + "name": "profile-settings", + "children": [] + }, + { + "id": 19, + "name": "notification-settings", + "children": [] + } + ] + } + ] + } + ] + }, + { + "id": 20, + "name": "app-root", + "children": [ + { + "id": 21, + "name": "user-flow", + "children": [ + { + "id": 22, + "name": "signup-screen", + "children": [ + { + "id": 23, + "name": "registration-form", + "children": [] + }, + { + "id": 24, + "name": "oauth-signup", + "children": [] + } + ] + } + ] + }, + { + "id": 25, + "name": "content-flow", + "children": [ + { + "id": 26, + "name": "home-screen", + "children": [ + { + "id": 27, + "name": "featured-content", + "children": [] + }, + { + "id": 28, + "name": "user-feed", + "children": [] + } + ] + } + ] + }, + { + "id": 29, + "name": "system-tasks", + "children": [ + { + "id": 30, + "name": "data-service", + "children": [ + { + "id": 31, + "name": "content-sync", + "children": [] + }, + { + "id": 32, + "name": "cache-manager", + "children": [] + } + ] + }, + { + "id": 33, + "name": "analytics-service", + "children": [ + { + "id": 34, + "name": "event-tracker", + "children": [] + } + ] + } + ] + }, + { + "id": 35, + "name": "preferences-flow", + "children": [ + { + "id": 36, + "name": "preferences-screen", + "children": [ + { + "id": 37, + "name": "account-settings", + "children": [] + }, + { + "id": 38, + "name": "privacy-settings", + "children": [] + } + ] + } + ] + } + ] + }, + { + "id": 39, + "name": "system-root", + "children": [ + { + "id": 40, + "name": "security-flow", + "children": [ + { + "id": 41, + "name": "auth-screen", + "children": [ + { + "id": 42, + "name": "biometric-auth", + "children": [] + }, + { + "id": 43, + "name": "pin-verification", + "children": [] + } + ] + } + ] + }, + { + "id": 44, + "name": "navigation-flow", + "children": [ + { + "id": 45, + "name": "tab-navigator", + "children": [ + { + "id": 46, + "name": "home-tab", + "children": [] + }, + { + "id": 47, + "name": "search-tab", + "children": [] + } + ] + } + ] + }, + { + "id": 48, + "name": "core-services", + "children": [ + { + "id": 49, + "name": "network-service", + "children": [ + { + "id": 50, + "name": "api-client", + "children": [] + }, + { + "id": 51, + "name": "offline-handler", + "children": [] + } + ] + }, + { + "id": 52, + "name": "storage-service", + "children": [ + { + "id": 53, + "name": "database-manager", + "children": [] + } + ] + } + ] + }, + { + "id": 54, + "name": "admin-flow", + "children": [ + { + "id": 55, + "name": "admin-panel", + "children": [ + { + "id": 56, + "name": "user-management", + "children": [] + }, + { + "id": 57, + "name": "system-monitoring", + "children": [] + } + ] + } + ] + } + ] + }, + { + "id": 58, + "name": "commerce-root", + "children": [ + { + "id": 59, + "name": "payment-flow", + "children": [ + { + "id": 60, + "name": "checkout-screen", + "children": [ + { + "id": 61, + "name": "payment-form", + "children": [] + }, + { + "id": 62, + "name": "card-scanner", + "children": [] + } + ] + } + ] + }, + { + "id": 63, + "name": "catalog-flow", + "children": [ + { + "id": 64, + "name": "product-list", + "children": [ + { + "id": 65, + "name": "product-card", + "children": [] + }, + { + "id": 66, + "name": "filter-panel", + "children": [] + } + ] + } + ] + }, + { + "id": 67, + "name": "order-tasks", + "children": [ + { + "id": 68, + "name": "fulfillment-service", + "children": [ + { + "id": 69, + "name": "inventory-check", + "children": [] + }, + { + "id": 70, + "name": "shipping-calc", + "children": [] + } + ] + }, + { + "id": 71, + "name": "payment-service", + "children": [ + { + "id": 72, + "name": "transaction-processor", + "children": [] + } + ] + } + ] + }, + { + "id": 73, + "name": "merchant-flow", + "children": [ + { + "id": 74, + "name": "seller-dashboard", + "children": [ + { + "id": 75, + "name": "sales-analytics", + "children": [] + }, + { + "id": 76, + "name": "inventory-manager", + "children": [] + } + ] + } + ] + } + ] + }, + { + "id": 77, + "name": "social-root", + "children": [ + { + "id": 78, + "name": "messaging-flow", + "children": [ + { + "id": 79, + "name": "chat-screen", + "children": [ + { + "id": 80, + "name": "message-input", + "children": [] + }, + { + "id": 81, + "name": "media-picker", + "children": [] + } + ] + } + ] + }, + { + "id": 82, + "name": "social-flow", + "children": [ + { + "id": 83, + "name": "timeline-screen", + "children": [ + { + "id": 84, + "name": "post-composer", + "children": [] + }, + { + "id": 85, + "name": "story-viewer", + "children": [] + } + ] + } + ] + }, + { + "id": 86, + "name": "connection-tasks", + "children": [ + { + "id": 87, + "name": "friend-service", + "children": [ + { + "id": 88, + "name": "contact-sync", + "children": [] + }, + { + "id": 89, + "name": "suggestion-engine", + "children": [] + } + ] + }, + { + "id": 90, + "name": "activity-service", + "children": [ + { + "id": 91, + "name": "feed-generator", + "children": [] + } + ] + } + ] + }, + { + "id": 92, + "name": "privacy-flow", + "children": [ + { + "id": 93, + "name": "privacy-screen", + "children": [ + { + "id": 94, + "name": "visibility-controls", + "children": [] + }, + { + "id": 95, + "name": "block-manager", + "children": [] + } + ] + } + ] + } + ] + }, + { + "id": 96, + "name": "media-root", + "children": [ + { + "id": 97, + "name": "streaming-flow", + "children": [ + { + "id": 98, + "name": "player-screen", + "children": [ + { + "id": 99, + "name": "video-player", + "children": [] + }, + { + "id": 100, + "name": "audio-controls", + "children": [] + } + ] + } + ] + }, + { + "id": 101, + "name": "library-flow", + "children": [ + { + "id": 102, + "name": "media-browser", + "children": [ + { + "id": 103, + "name": "playlist-view", + "children": [] + }, + { + "id": 104, + "name": "search-results", + "children": [] + } + ] + } + ] + }, + { + "id": 105, + "name": "processing-tasks", + "children": [ + { + "id": 106, + "name": "encoding-service", + "children": [ + { + "id": 107, + "name": "video-encoder", + "children": [] + }, + { + "id": 108, + "name": "thumbnail-generator", + "children": [] + } + ] + }, + { + "id": 109, + "name": "cdn-service", + "children": [ + { + "id": 110, + "name": "content-distributor", + "children": [] + } + ] + } + ] + }, + { + "id": 111, + "name": "creator-flow", + "children": [ + { + "id": 112, + "name": "upload-screen", + "children": [ + { + "id": 113, + "name": "file-uploader", + "children": [] + }, + { + "id": 114, + "name": "metadata-editor", + "children": [] + } + ] + } + ] + } + ] + }, + { + "id": 115, + "name": "finance-root", + "children": [ + { + "id": 116, + "name": "banking-flow", + "children": [ + { + "id": 117, + "name": "account-screen", + "children": [ + { + "id": 118, + "name": "balance-widget", + "children": [] + }, + { + "id": 119, + "name": "transaction-list", + "children": [] + } + ] + } + ] + }, + { + "id": 120, + "name": "transfer-flow", + "children": [ + { + "id": 121, + "name": "send-money-screen", + "children": [ + { + "id": 122, + "name": "recipient-selector", + "children": [] + }, + { + "id": 123, + "name": "amount-input", + "children": [] + } + ] + } + ] + }, + { + "id": 124, + "name": "financial-tasks", + "children": [ + { + "id": 125, + "name": "fraud-service", + "children": [ + { + "id": 126, + "name": "risk-analyzer", + "children": [] + }, + { + "id": 127, + "name": "pattern-detector", + "children": [] + } + ] + }, + { + "id": 128, + "name": "compliance-service", + "children": [ + { + "id": 129, + "name": "kyc-validator", + "children": [] + } + ] + } + ] + }, + { + "id": 130, + "name": "investment-flow", + "children": [ + { + "id": 131, + "name": "portfolio-screen", + "children": [ + { + "id": 132, + "name": "asset-overview", + "children": [] + }, + { + "id": 133, + "name": "performance-chart", + "children": [] + } + ] + } + ] + } + ] + }, + { + "id": 134, + "name": "health-root", + "children": [ + { + "id": 135, + "name": "tracking-flow", + "children": [ + { + "id": 136, + "name": "activity-screen", + "children": [ + { + "id": 137, + "name": "step-counter", + "children": [] + }, + { + "id": 138, + "name": "workout-tracker", + "children": [] + } + ] + } + ] + }, + { + "id": 139, + "name": "wellness-flow", + "children": [ + { + "id": 140, + "name": "health-dashboard", + "children": [ + { + "id": 141, + "name": "vital-signs", + "children": [] + }, + { + "id": 142, + "name": "medication-reminder", + "children": [] + } + ] + } + ] + }, + { + "id": 143, + "name": "monitoring-tasks", + "children": [ + { + "id": 144, + "name": "sensor-service", + "children": [ + { + "id": 145, + "name": "heart-rate-monitor", + "children": [] + }, + { + "id": 146, + "name": "sleep-tracker", + "children": [] + } + ] + }, + { + "id": 147, + "name": "analysis-service", + "children": [ + { + "id": 148, + "name": "trend-analyzer", + "children": [] + } + ] + } + ] + }, + { + "id": 149, + "name": "consultation-flow", + "children": [ + { + "id": 150, + "name": "telemedicine-screen", + "children": [ + { + "id": 151, + "name": "video-call", + "children": [] + }, + { + "id": 152, + "name": "symptom-checker", + "children": [] + } + ] + } + ] + } + ] + }, + { + "id": 153, + "name": "education-root", + "children": [ + { + "id": 154, + "name": "learning-flow", + "children": [ + { + "id": 155, + "name": "course-screen", + "children": [ + { + "id": 156, + "name": "video-lesson", + "children": [] + }, + { + "id": 157, + "name": "quiz-component", + "children": [] + } + ] + } + ] + }, + { + "id": 158, + "name": "progress-flow", + "children": [ + { + "id": 159, + "name": "achievement-screen", + "children": [ + { + "id": 160, + "name": "badge-display", + "children": [] + }, + { + "id": 161, + "name": "progress-tracker", + "children": [] + } + ] + } + ] + }, + { + "id": 162, + "name": "assessment-tasks", + "children": [ + { + "id": 163, + "name": "grading-service", + "children": [ + { + "id": 164, + "name": "auto-grader", + "children": [] + }, + { + "id": 165, + "name": "feedback-generator", + "children": [] + } + ] + }, + { + "id": 166, + "name": "analytics-service", + "children": [ + { + "id": 167, + "name": "learning-analyzer", + "children": [] + } + ] + } + ] + }, + { + "id": 168, + "name": "collaboration-flow", + "children": [ + { + "id": 169, + "name": "study-group-screen", + "children": [ + { + "id": 170, + "name": "whiteboard-tool", + "children": [] + }, + { + "id": 171, + "name": "discussion-forum", + "children": [] + } + ] + } + ] + } + ] + }, + { + "id": 172, + "name": "travel-root", + "children": [ + { + "id": 173, + "name": "booking-flow", + "children": [ + { + "id": 174, + "name": "search-screen", + "children": [ + { + "id": 175, + "name": "flight-finder", + "children": [] + }, + { + "id": 176, + "name": "hotel-search", + "children": [] + } + ] + } + ] + }, + { + "id": 177, + "name": "itinerary-flow", + "children": [ + { + "id": 178, + "name": "trip-planner", + "children": [ + { + "id": 179, + "name": "schedule-builder", + "children": [] + }, + { + "id": 180, + "name": "map-integration", + "children": [] + } + ] + } + ] + }, + { + "id": 181, + "name": "travel-tasks", + "children": [ + { + "id": 182, + "name": "booking-service", + "children": [ + { + "id": 183, + "name": "reservation-manager", + "children": [] + }, + { + "id": 184, + "name": "price-tracker", + "children": [] + } + ] + }, + { + "id": 185, + "name": "notification-service", + "children": [ + { + "id": 186, + "name": "flight-alerts", + "children": [] + } + ] + } + ] + }, + { + "id": 187, + "name": "companion-flow", + "children": [ + { + "id": 188, + "name": "travel-guide", + "children": [ + { + "id": 189, + "name": "local-recommendations", + "children": [] + }, + { + "id": 190, + "name": "weather-widget", + "children": [] + } + ] + } + ] + } + ] + } +] diff --git a/workflow-tracing/api/workflow-tracing.api b/workflow-tracing/api/workflow-tracing.api index 2bb7c72e0..0892bdeb7 100644 --- a/workflow-tracing/api/workflow-tracing.api +++ b/workflow-tracing/api/workflow-tracing.api @@ -16,7 +16,7 @@ public final class com/squareup/workflow1/diagnostic/tracing/TracingWorkflowInte public fun onPropsChanged (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; public fun onRender (Ljava/lang/Object;Ljava/lang/Object;Lcom/squareup/workflow1/BaseRenderContext;Lkotlin/jvm/functions/Function3;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Ljava/lang/Object; public fun onRenderAndSnapshot (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/RenderingAndSnapshot; - public fun onRuntimeLoopTick (Lcom/squareup/workflow1/WorkflowInterceptor$RuntimeLoopOutcome;)V + public fun onRuntimeUpdate (Lcom/squareup/workflow1/WorkflowInterceptor$RuntimeUpdate;)V public fun onSessionStarted (Lkotlinx/coroutines/CoroutineScope;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)V public fun onSnapshotState (Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/Snapshot; public fun onSnapshotStateWithChildren (Lkotlin/jvm/functions/Function0;Lcom/squareup/workflow1/WorkflowInterceptor$WorkflowSession;)Lcom/squareup/workflow1/TreeSnapshot; diff --git a/workflow-ui/compose/README.md b/workflow-ui/compose/README.md index c58169ff4..6e0c9a752 100644 --- a/workflow-ui/compose/README.md +++ b/workflow-ui/compose/README.md @@ -157,20 +157,30 @@ renderWorkflowIn( #### Defining Compose-based UI factories -The most straightforward and common way to tie a `Screen` rendering type to a `@Composable` function is to implement [`ComposeScreen`](https://github.com/square/workflow-kotlin/blob/9bfd5119fabd0a3dfbc25bf7d93e52c7b31bb4cd/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ComposeScreen.kt), the Compose-friendly analog to [`AndroidScreen`](https://github.com/square/workflow-kotlin/blob/v1.12.1-beta06/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/AndroidScreen.kt). +The most straightforward and common way to tie a `Screen` rendering type to a `@Composable` function +is to implement [`ComposeScreen`](https://github.com/square/workflow-kotlin/blob/9bfd5119fabd0a3dfbc25bf7d93e52c7b31bb4cd/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ComposeScreen.kt), the Compose-friendly analog to [`AndroidScreen`](https://github.com/square/workflow-kotlin/blob/v1.12.1-beta06/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/AndroidScreen.kt). ```kotlin - data class HelloScreen( - val message: String, - val onClick: () -> Unit - ) : ComposeScreen { - - @Composable override fun Content(viewEnvironment: ViewEnvironment) { - Button(onClick) { - Text(message) - } - } - } +import java.nio.file.WatchEvent.Modifier + +data class HelloScreen( + val message: String, + val onClick: () -> Unit +) : ComposeScreen { + @Composable override fun Content() { + Hello(this) + } +} + +@Composable +private fun Hello( + screen: HelloScreen, + modifier: Modifier = Modifier +) { + Button(screen.onClick, modifier) { + Text(message) + } +} ``` `ComposeScreen` is a convenience that automates creating a `ScreenComposableFactory` implementation responsible for expressing, say, `HelloScreen` instances by calling `HelloScreen.Content()`. @@ -186,10 +196,10 @@ data class ContactScreen( ): Screen ``` ```kotlin -val contactUiFactory = ScreenComposableFactory { rendering, viewEnvironment -> +val contactUiFactory = ScreenComposableFactory { screen -> Column { - Text(rendering.name) - Text(rendering.phoneNumber) + Text(screen.name) + Text(screen.phoneNumber) } } @@ -215,7 +225,6 @@ Aka, `WorkflowViewStub` — Compose Edition! The idea of “view stub” is nons ```kotlin @Composable fun WorkflowRendering( rendering: Screen, - viewEnvironment: ViewEnvironment, modifier: Modifier = Modifier ) ``` @@ -230,13 +239,12 @@ data class ContactScreen( val details: Screen ): Screen -val contactUiFactory = ScreenComposableFactory { rendering, viewEnvironment -> +val contactUiFactory = ScreenComposableFactory { screen -> Column { - Text(rendering.name) + Text(screen.name) WorkflowRendering( - rendering.details, - viewEnvironment, + screen.details, Modifier.fillMaxWidth() ) } @@ -307,7 +315,6 @@ Here’s an example: ```kotlin @Composable fun App(rootWorkflow: Workflow<...>) { var rootProps by remember { mutableStateOf(...) } - val viewEnvironment = ... val rootRendering by rootWorkflow.renderAsState( props = rootProps @@ -315,7 +322,7 @@ Here’s an example: handleOutput(output) } - WorkflowRendering(rootRendering, viewEnvironment) + WorkflowRendering(rootRendering) } ``` @@ -323,20 +330,36 @@ Here’s an example: ## Potential risk: Data model -Passing both the rendering and view environment down as parameters through the entire UI tree means that every time a rendering updates, we’ll recompose a lot of composables. This is how Workflow was designed, and because compose does some automatic deduping we’ll automatically avoid recomposing the leaves of the UI for a particular view factory unless the data for those bits of ui actually change. However, any time a leaf rendering changes, we’ll also be recomposing all the parent view factories just in order to propagate that leaf to its composable. That means we’re not able to take advantage of a lot of the other optimizations that compose tries to do both now and potentially in the future. +Passing both rendering down as a parameter through the entire UI tree means that +every time a rendering updates, we’ll recompose a lot of composables. +This is how Workflow was designed, and because compose does some automatic deduping +we’ll automatically avoid recomposing the leaves of the UI for a particular view factory +unless the data for those bits of ui actually change. However, any time a leaf rendering changes, +we’ll also be recomposing all the parent view factories just in order to propagate that leaf to its composable. +That means we’re not able to take advantage of a lot of the other optimizations that compose tries to do both now and potentially in the future. In other words: “Workflow+views” < “Workflow+compose” < “data model designed specifically for compose + compose”. -It should be straightforward to address this issue for view environments - see the _Alternative design_ section for more information. However, it’s not clear how to solve this for renderings without abandoning our current rendering data model. Today, renderings are an immutable tree of immutable value types that require the entire tree to be recreated any time any single piece of data changes. The reason for this design is that it was the only way to safely propagate changes without adding a bunch of reactive streams to renderings everywhere. The key word in that sentence is “was”: Compose’s snapshot state system makes it possible to expose simple mutable properties and still get change notifications that will ensure that the UI stays up-to-date (For an example of how this system can be used to model complex state systems with dependencies, see [this blog post](https://dev.to/zachklipp/plumbing-data-with-derived-state-in-compose-53ka)). - -Workflow could take advantage of this by allowing renderings to actually be mutable, so that when one Workflow deep in the tree wants to change something, it can do so independently and without requiring every rendering above it in the tree to also change. Making such a change to such a fundamental piece of Workflow design could have significant implications on other aspects of Workflow design, and doing so is very far outside the scope of this post. - -We want to call this out because it seems like we’ll be losing out on one of Compose’s optimization tricks, but we’re not sure how much of a problem this will turn out to be in the real world. The only performance issues that we’re aware of that we’ve run into with Workflow UI so far are issues with recreating leaf views on every rerender, and that in particular _*is*_ something Compose will automatically win at, even with our current data model. - -## Alternative design: Propagating `ViewEnvironment`s through `CompositionLocal`s - -You’ll notice that all the APIs described above explicitly pass `ViewEnvironment` objects around. This mirrors how other Workflow UI code works, as well as the Mosaic integration. Compose has the concept of “composition local” — which is similar in spirit to `ViewEnvironment` itself (and SwiftUI’s [`Environment`](https://developer.apple.com/documentation/swiftui/environment)). So why not just pass view environments implicitly through composition locals? - -This is what we did at first, but it made the API awkward for testing and other cases. Google advises against using composition locals in most cases for a reason. Because Workflow UI requires a `ViewRegistry` to be provided through the `ViewEnvironment`, there’s no obvious default value — what is the correct behavior when no `ViewEnvironment` local has been specified? Crashing at runtime is not ideal. We could provide an empty `ViewRegistry`, but that’s just another way to crash at runtime a few levels deeper in the call stack. Requiring explicit parameters for `ViewEnvironment` solves all these problems at the expense of a little more typing, and matches how the existing `ViewFactory` APIs work. - -On the other hand, providing an API to access individual view environment elements from a composable that hides the actual mechanism and uses composition locals under the hood would let us take much better advantage of Compose’s fine-grained UI updates. We could ensure that, when a view environment changes, only the parts of the UI that actually care about the modified part of the environment are recomposed. However, renderings typically change an order of magnitude more frequently than view environments, so there’s probably not much point solving this problem until we’ve solved the same problem with renderings (discussed above under _Potential risk: Data model_). +It’s not clear how to solve this for renderings without abandoning our current rendering data model. +Today, renderings are an immutable tree of immutable value types +that require the entire tree to be recreated any time any single piece of data changes. +The reason for this design is that it was the only way to safely propagate changes +without adding a bunch of reactive streams to renderings everywhere. + +The key word in that sentence is “was”: Compose’s snapshot state system makes it possible +to expose simple mutable properties and still get change notifications that will ensure +that the UI stays up-to-date. +For an example of how this system can be used to model complex state systems with dependencies, +see [this blog post](https://dev.to/zachklipp/plumbing-data-with-derived-state-in-compose-53ka). + +Workflow could take advantage of this by allowing renderings to actually be mutable, +so that when one Workflow deep in the tree wants to change something, it can do so independently +and without requiring every rendering above it in the tree to also change. +Making such a change to such a fundamental piece of Workflow design could have significant implications +on other aspects of Workflow design, and doing so is very far outside the scope of this post. + +We want to call this out because it seems like we’ll be losing out on one of Compose’s optimization tricks, +but we’re not sure how much of a problem this will turn out to be in the real world. +The only performance issues that we’re aware of that we’ve run into with Workflow UI so far are issues +with recreating leaf views on every rerender, and that in particular _*is*_ something Compose will automatically win at, +even with our current data model. diff --git a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ComposeScreen.kt b/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ComposeScreen.kt index 1659da2fe..218b0c87a 100644 --- a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ComposeScreen.kt +++ b/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ComposeScreen.kt @@ -24,6 +24,9 @@ import com.squareup.workflow1.ui.ViewRegistry * * Note that unlike most workflow view functions, [Content] does not take the rendering as a * parameter. Instead, the rendering is the receiver, i.e. the current value of `this`. + * Despite this (perhaps unfortunate) convenience, it is best to keep your `Content()` + * function as lean as possible to avoid interfering with Composes + * [stability calculations](https://developer.android.com/develop/ui/compose/performance/stability). * * Example: * @@ -31,17 +34,29 @@ import com.squareup.workflow1.ui.ViewRegistry * val message: String, * val onClick: () -> Unit * ) : ComposeScreen { + * @Composable override fun Content() { + * Hello(this) + * } + * } * - * @Composable override fun Content(viewEnvironment: ViewEnvironment) { - * Button(onClick) { - * Text(message) + * @Composable + * private fun Hello( + * screen: HelloScreen, + * modifier: Modifier = Modifier + * ) { + * Button(screen.onClick, modifier) { + * Text(screen.message) * } - * } * } * - * This is the simplest way to bridge the gap between your workflows and the UI, but using it - * requires your workflows code to reside in Android modules and depend upon the Compose runtime, - * instead of being pure Kotlin. If this is a problem, or you need more flexibility for any other + * (Note that the example includes a `modifier` parameter that is not used by + * the `HelloScreen` itself. We recommend this approach to simplify + * previews and snapshot tests.) + * + * [ComposeScreen] is the simplest way to bridge the gap between your workflows and the UI, + * but using it requires your workflows code to reside in Android modules + * and depend upon the Compose runtime, instead of being pure Kotlin. + * If this is a problem, or you need more flexibility for any other * reason, you can use [ViewRegistry] to bind your renderings to [ScreenComposableFactory] * implementations at runtime. * diff --git a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder.kt b/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder.kt index 42004654c..5cbfa7780 100644 --- a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder.kt +++ b/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder.kt @@ -2,6 +2,8 @@ package com.squareup.workflow1.ui.compose import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember +import com.squareup.workflow1.internal.withKey +import com.squareup.workflow1.ui.Compatible.Companion.keyFor import com.squareup.workflow1.ui.EnvironmentScreen import com.squareup.workflow1.ui.NamedScreen import com.squareup.workflow1.ui.Screen @@ -72,5 +74,5 @@ public fun ScreenComposableFactoryFinder.requireComposableFac environment[ViewRegistry] .getEntryFor(Key(rendering::class, ScreenComposableFactory::class)) }." - ) + ).withKey(keyFor(rendering)) } diff --git a/workflow-ui/core-android/api/core-android.api b/workflow-ui/core-android/api/core-android.api index a9530a8e1..1f462ae97 100644 --- a/workflow-ui/core-android/api/core-android.api +++ b/workflow-ui/core-android/api/core-android.api @@ -1,11 +1,6 @@ -public final class com/squareup/workflow1/ui/AndroidRenderWorkflowKt { - public static final fun removeWorkflowState (Landroidx/lifecycle/SavedStateHandle;)V - public static final fun renderWorkflowIn (Lcom/squareup/workflow1/Workflow;Lkotlinx/coroutines/CoroutineScope;Landroidx/lifecycle/SavedStateHandle;Ljava/util/List;Ljava/util/Set;Lcom/squareup/workflow1/WorkflowTracer;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/flow/StateFlow; - public static final fun renderWorkflowIn (Lcom/squareup/workflow1/Workflow;Lkotlinx/coroutines/CoroutineScope;Ljava/lang/Object;Landroidx/lifecycle/SavedStateHandle;Ljava/util/List;Ljava/util/Set;Lcom/squareup/workflow1/WorkflowTracer;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/flow/StateFlow; - public static final fun renderWorkflowIn (Lcom/squareup/workflow1/Workflow;Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/flow/StateFlow;Landroidx/lifecycle/SavedStateHandle;Ljava/util/List;Ljava/util/Set;Lcom/squareup/workflow1/WorkflowTracer;Lkotlin/jvm/functions/Function2;)Lkotlinx/coroutines/flow/StateFlow; - public static synthetic fun renderWorkflowIn$default (Lcom/squareup/workflow1/Workflow;Lkotlinx/coroutines/CoroutineScope;Landroidx/lifecycle/SavedStateHandle;Ljava/util/List;Ljava/util/Set;Lcom/squareup/workflow1/WorkflowTracer;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lkotlinx/coroutines/flow/StateFlow; - public static synthetic fun renderWorkflowIn$default (Lcom/squareup/workflow1/Workflow;Lkotlinx/coroutines/CoroutineScope;Ljava/lang/Object;Landroidx/lifecycle/SavedStateHandle;Ljava/util/List;Ljava/util/Set;Lcom/squareup/workflow1/WorkflowTracer;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lkotlinx/coroutines/flow/StateFlow; - public static synthetic fun renderWorkflowIn$default (Lcom/squareup/workflow1/Workflow;Lkotlinx/coroutines/CoroutineScope;Lkotlinx/coroutines/flow/StateFlow;Landroidx/lifecycle/SavedStateHandle;Ljava/util/List;Ljava/util/Set;Lcom/squareup/workflow1/WorkflowTracer;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lkotlinx/coroutines/flow/StateFlow; +public final class com/squareup/workflow1/ui/ActivityWorkflowContentViewKt { + public static final fun getWorkflowContentView (Landroid/app/Activity;)Lcom/squareup/workflow1/ui/WorkflowLayout; + public static final fun getWorkflowContentViewOrNull (Landroid/app/Activity;)Lcom/squareup/workflow1/ui/WorkflowLayout; } public abstract interface class com/squareup/workflow1/ui/AndroidScreen : com/squareup/workflow1/ui/Screen { diff --git a/workflow-ui/core-android/build.gradle.kts b/workflow-ui/core-android/build.gradle.kts index 89adc8b26..1d489d7e8 100644 --- a/workflow-ui/core-android/build.gradle.kts +++ b/workflow-ui/core-android/build.gradle.kts @@ -23,6 +23,7 @@ dependencies { // Needs to be API for the WorkflowInterceptor argument to WorkflowRunner.Config. api(project(":workflow-runtime")) + api(project(":workflow-runtime-android")) api(project(":workflow-ui:core-common")) compileOnly(libs.androidx.viewbinding) diff --git a/workflow-ui/core-android/dependencies/releaseRuntimeClasspath.txt b/workflow-ui/core-android/dependencies/releaseRuntimeClasspath.txt index 8ad63aa2f..ab2f7f1db 100644 --- a/workflow-ui/core-android/dependencies/releaseRuntimeClasspath.txt +++ b/workflow-ui/core-android/dependencies/releaseRuntimeClasspath.txt @@ -1,15 +1,38 @@ +androidx.activity:activity-ktx:1.8.2 androidx.activity:activity:1.8.2 -androidx.annotation:annotation-experimental:1.4.0 +androidx.annotation:annotation-experimental:1.4.1 androidx.annotation:annotation-jvm:1.8.1 androidx.annotation:annotation:1.8.1 androidx.arch.core:core-common:2.2.0 androidx.arch.core:core-runtime:2.2.0 -androidx.collection:collection:1.1.0 +androidx.autofill:autofill:1.0.0 +androidx.collection:collection-jvm:1.4.4 +androidx.collection:collection-ktx:1.4.4 +androidx.collection:collection:1.4.4 +androidx.compose.runtime:runtime-android:1.7.2 +androidx.compose.runtime:runtime-saveable-android:1.7.2 +androidx.compose.runtime:runtime-saveable:1.7.2 +androidx.compose.runtime:runtime:1.7.2 +androidx.compose.ui:ui-android:1.7.2 +androidx.compose.ui:ui-geometry-android:1.7.2 +androidx.compose.ui:ui-geometry:1.7.2 +androidx.compose.ui:ui-graphics-android:1.7.2 +androidx.compose.ui:ui-graphics:1.7.2 +androidx.compose.ui:ui-text-android:1.7.2 +androidx.compose.ui:ui-text:1.7.2 +androidx.compose.ui:ui-unit-android:1.7.2 +androidx.compose.ui:ui-unit:1.7.2 +androidx.compose.ui:ui-util-android:1.7.2 +androidx.compose.ui:ui-util:1.7.2 +androidx.compose:compose-bom:2024.09.02 androidx.concurrent:concurrent-futures:1.1.0 androidx.core:core-ktx:1.13.1 androidx.core:core:1.13.1 +androidx.customview:customview-poolingcontainer:1.0.0 androidx.documentfile:documentfile:1.0.0 androidx.dynamicanimation:dynamicanimation:1.0.0 +androidx.emoji2:emoji2:1.2.0 +androidx.graphics:graphics-path:1.0.1 androidx.interpolator:interpolator:1.0.0 androidx.legacy:legacy-support-core-utils:1.0.0 androidx.lifecycle:lifecycle-common-jvm:2.8.7 @@ -17,17 +40,22 @@ androidx.lifecycle:lifecycle-common:2.8.7 androidx.lifecycle:lifecycle-livedata-core-ktx:2.8.7 androidx.lifecycle:lifecycle-livedata-core:2.8.7 androidx.lifecycle:lifecycle-livedata:2.8.7 +androidx.lifecycle:lifecycle-process:2.8.7 androidx.lifecycle:lifecycle-runtime-android:2.8.7 +androidx.lifecycle:lifecycle-runtime-compose-android:2.8.7 +androidx.lifecycle:lifecycle-runtime-compose:2.8.7 androidx.lifecycle:lifecycle-runtime-ktx-android:2.8.7 androidx.lifecycle:lifecycle-runtime-ktx:2.8.7 androidx.lifecycle:lifecycle-runtime:2.8.7 androidx.lifecycle:lifecycle-viewmodel-android:2.8.7 +androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7 androidx.lifecycle:lifecycle-viewmodel-savedstate:2.8.7 androidx.lifecycle:lifecycle-viewmodel:2.8.7 androidx.loader:loader:1.0.0 androidx.localbroadcastmanager:localbroadcastmanager:1.0.0 androidx.print:print:1.0.0 androidx.profileinstaller:profileinstaller:1.3.1 +androidx.savedstate:savedstate-ktx:1.2.1 androidx.savedstate:savedstate:1.2.1 androidx.startup:startup-runtime:1.1.1 androidx.tracing:tracing:1.0.0 diff --git a/workflow-ui/core-android/src/androidTest/java/com/squareup/workflow1/ui/navigation/DialogIntegrationTest.kt b/workflow-ui/core-android/src/androidTest/java/com/squareup/workflow1/ui/navigation/DialogIntegrationTest.kt index 58c3463ed..8892d96f8 100644 --- a/workflow-ui/core-android/src/androidTest/java/com/squareup/workflow1/ui/navigation/DialogIntegrationTest.kt +++ b/workflow-ui/core-android/src/androidTest/java/com/squareup/workflow1/ui/navigation/DialogIntegrationTest.kt @@ -22,8 +22,8 @@ import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.ScreenViewFactory import com.squareup.workflow1.ui.ScreenViewHolder import com.squareup.workflow1.ui.ViewEnvironment -import com.squareup.workflow1.ui.WorkflowLayout import com.squareup.workflow1.ui.withEnvironment +import com.squareup.workflow1.ui.workflowContentView import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -76,9 +76,7 @@ internal class DialogIntegrationTest { ) scenario.onActivity { activity -> - val root = WorkflowLayout(activity) - activity.setContentView(root) - root.show(screen) + activity.workflowContentView.show(screen) } onView(withText("content")).inRoot(isDialog()).check(matches(isDisplayed())) @@ -92,12 +90,9 @@ internal class DialogIntegrationTest { ContentRendering("body"), listOf(DialogRendering("dialog", ContentRendering("content"))) ) - lateinit var root: WorkflowLayout scenario.onActivity { activity -> - root = WorkflowLayout(activity) - activity.setContentView(root) - root.show(oneDialog) + activity.workflowContentView.show(oneDialog) } val dialog2 = DialogRendering("dialog2", ContentRendering("content2")) @@ -109,8 +104,8 @@ internal class DialogIntegrationTest { ) ) - scenario.onActivity { - root.show(twoDialogs) + scenario.onActivity { activity -> + activity.workflowContentView.show(twoDialogs) val lastOverlay = latestDialog?.overlay assertThat(lastOverlay).isEqualTo(dialog2) } @@ -128,12 +123,8 @@ internal class DialogIntegrationTest { listOf(DialogRendering("dialog", ContentRendering("content"))) ).withEnvironment(stickyEnvironment) - lateinit var root: WorkflowLayout - scenario.onActivity { activity -> - root = WorkflowLayout(activity) - activity.setContentView(root) - root.show(oneDialog) + activity.workflowContentView.show(oneDialog) } val dialog2 = DialogRendering("dialog2", ContentRendering("content2")) @@ -145,8 +136,8 @@ internal class DialogIntegrationTest { ) ).withEnvironment(stickyEnvironment) - scenario.onActivity { - root.show(twoDialogs) + scenario.onActivity { activity -> + activity.workflowContentView.show(twoDialogs) val lastOverlay = latestDialog?.overlay assertThat(lastOverlay).isEqualTo(dialog2) } @@ -158,18 +149,15 @@ internal class DialogIntegrationTest { val overlayZero = DialogRendering("dialog0", ContentRendering("content")) val overlayOne = DialogRendering("dialog1", ContentRendering("content")) val showingBoth = BodyAndOverlaysScreen(body, listOf(overlayZero, overlayOne)) - lateinit var root: WorkflowLayout lateinit var originalDialogOne: Dialog scenario.onActivity { activity -> - root = WorkflowLayout(activity) - activity.setContentView(root) - root.show(showingBoth) + activity.workflowContentView.show(showingBoth) originalDialogOne = latestDialog!! assertThat(originalDialogOne.overlayOrNull).isSameInstanceAs(overlayOne) } val closedZero = BodyAndOverlaysScreen(body, listOf(overlayOne)) - scenario.onActivity { - root.show(closedZero) + scenario.onActivity { activity -> + activity.workflowContentView.show(closedZero) assertThat(latestDialog!!.overlayOrNull).isSameInstanceAs(overlayOne) assertThat(latestDialog).isSameInstanceAs(originalDialogOne) } @@ -182,9 +170,7 @@ internal class DialogIntegrationTest { ) scenario.onActivity { activity -> - val root = WorkflowLayout(activity) - activity.setContentView(root) - root.show(screen) + activity.workflowContentView.show(screen) } onView(withText("content")).inRoot(isDialog()).check(matches(isDisplayed())) diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/ActivityWorkflowContentView.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/ActivityWorkflowContentView.kt new file mode 100644 index 000000000..65a2f5b98 --- /dev/null +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/ActivityWorkflowContentView.kt @@ -0,0 +1,24 @@ +package com.squareup.workflow1.ui + +import android.app.Activity +import android.view.View + +/** + * Returns the [WorkflowLayout] serving as the [contentView][Activity.setContentView] + * of the receiving [Activity], creating it (and replacing the existing view) if + * necessary. + */ +val Activity.workflowContentView: WorkflowLayout + get() { + return workflowContentViewOrNull ?: WorkflowLayout(this).also { + it.id = R.id.workflow_content_view + setContentView(it) + } + } + +/** + * Returns the [WorkflowLayout] serving as the [contentView][Activity.setContentView] + * of the receiving [Activity], or null if there isn't one. + */ +val Activity.workflowContentViewOrNull: WorkflowLayout? + get() = (findViewById(R.id.workflow_content_view) as? WorkflowLayout) diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/ScreenViewFactoryFinder.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/ScreenViewFactoryFinder.kt index a8da052b1..78deacc72 100644 --- a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/ScreenViewFactoryFinder.kt +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/ScreenViewFactoryFinder.kt @@ -1,5 +1,7 @@ package com.squareup.workflow1.ui +import com.squareup.workflow1.internal.withKey +import com.squareup.workflow1.ui.Compatible.Companion.keyFor import com.squareup.workflow1.ui.ScreenViewFactory.Companion.forWrapper import com.squareup.workflow1.ui.ViewRegistry.Key import com.squareup.workflow1.ui.navigation.BackStackScreen @@ -98,5 +100,5 @@ public fun ScreenViewFactoryFinder.requireViewFactoryForRende "ViewEnvironment.withComposeInteropSupport() " + "from module com.squareup.workflow1:workflow-ui-compose at the top " + "of your Android view hierarchy." - ) + ).withKey(keyFor(rendering)) } diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/androidx/WorkflowSavedStateRegistryAggregator.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/androidx/WorkflowSavedStateRegistryAggregator.kt index e4f70105e..447ffbcfb 100644 --- a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/androidx/WorkflowSavedStateRegistryAggregator.kt +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/androidx/WorkflowSavedStateRegistryAggregator.kt @@ -11,6 +11,8 @@ import androidx.lifecycle.findViewTreeLifecycleOwner import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.findViewTreeSavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner +import com.squareup.workflow1.internal.requireNotNullWithKey +import com.squareup.workflow1.internal.withKey /** * Manages a group of [SavedStateRegistryOwner]s that are all saved to @@ -106,6 +108,7 @@ public class WorkflowSavedStateRegistryAggregator { } catch (e: IllegalStateException) { // Exception thrown by SavedStateRegistryOwner is pretty useless. throw IllegalStateException("Error consuming $parentKey from $parentRegistryOwner", e) + .withKey(parentKey.orEmpty()) } restoreFromBundle(restoredState) } @@ -163,7 +166,7 @@ public class WorkflowSavedStateRegistryAggregator { "Compatible.compatibilityKey -- note the name fields on BodyAndOverlaysScreen " + "and BackStackScreen.", e - ) + ).withKey(key) } // Even if the parent lifecycle is in a state further than CREATED, new observers are sent all @@ -223,20 +226,21 @@ public class WorkflowSavedStateRegistryAggregator { key: String, force: Boolean = false ) { - val lifecycleOwner = requireNotNull(view.findViewTreeLifecycleOwner()) { + val lifecycleOwner = requireNotNullWithKey(view.findViewTreeLifecycleOwner(), key) { "Expected $view($key) to have a ViewTreeLifecycleOwner. " + "Use WorkflowLifecycleOwner to fix that." } val registryOwner = KeyedSavedStateRegistryOwner(key, lifecycleOwner) children.put(key, registryOwner)?.let { throw IllegalArgumentException("$key is already in use, it cannot be used to register $view") + .withKey(key) } view.findViewTreeSavedStateRegistryOwner() ?.takeIf { !force || it is KeyedSavedStateRegistryOwner } ?.let { throw IllegalArgumentException( "Using $key to register $view, but it already has SavedStateRegistryOwner: $it" - ) + ).withKey(key) } view.setViewTreeSavedStateRegistryOwner(registryOwner) restoreIfOwnerReady(registryOwner) @@ -253,6 +257,7 @@ public class WorkflowSavedStateRegistryAggregator { public fun saveAndPruneChildRegistryOwner(key: String) { children.remove(key)?.let { saveIfOwnerReady(it) } ?: throw IllegalArgumentException("No such child: $key, on parent $parentKey") + .withKey(key) } private fun saveIfOwnerReady(child: KeyedSavedStateRegistryOwner) { diff --git a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/navigation/OverlayDialogFactoryFinder.kt b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/navigation/OverlayDialogFactoryFinder.kt index 741c78a20..7b5eabd77 100644 --- a/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/navigation/OverlayDialogFactoryFinder.kt +++ b/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/navigation/OverlayDialogFactoryFinder.kt @@ -1,5 +1,7 @@ package com.squareup.workflow1.ui.navigation +import com.squareup.workflow1.internal.withKey +import com.squareup.workflow1.ui.Compatible.Companion.keyFor import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.ViewEnvironmentKey @@ -31,7 +33,7 @@ public interface OverlayDialogFactoryFinder { ?: throw IllegalArgumentException( "An OverlayDialogFactory should have been registered to display $rendering, " + "or that class should implement AndroidOverlay. Instead found $entry." - ) + ).withKey(keyFor(rendering)) } public companion object : ViewEnvironmentKey() { diff --git a/workflow-ui/core-android/src/main/res/values/ids.xml b/workflow-ui/core-android/src/main/res/values/ids.xml index 49870f9dc..172694368 100644 --- a/workflow-ui/core-android/src/main/res/values/ids.xml +++ b/workflow-ui/core-android/src/main/res/values/ids.xml @@ -21,6 +21,8 @@ + + diff --git a/workflow-ui/radiography/dependencies/releaseRuntimeClasspath.txt b/workflow-ui/radiography/dependencies/releaseRuntimeClasspath.txt index 48859fad8..2541a3d3a 100644 --- a/workflow-ui/radiography/dependencies/releaseRuntimeClasspath.txt +++ b/workflow-ui/radiography/dependencies/releaseRuntimeClasspath.txt @@ -1,15 +1,38 @@ +androidx.activity:activity-ktx:1.8.2 androidx.activity:activity:1.8.2 -androidx.annotation:annotation-experimental:1.4.0 +androidx.annotation:annotation-experimental:1.4.1 androidx.annotation:annotation-jvm:1.8.1 androidx.annotation:annotation:1.8.1 androidx.arch.core:core-common:2.2.0 androidx.arch.core:core-runtime:2.2.0 -androidx.collection:collection:1.1.0 +androidx.autofill:autofill:1.0.0 +androidx.collection:collection-jvm:1.4.4 +androidx.collection:collection-ktx:1.4.4 +androidx.collection:collection:1.4.4 +androidx.compose.runtime:runtime-android:1.7.2 +androidx.compose.runtime:runtime-saveable-android:1.7.2 +androidx.compose.runtime:runtime-saveable:1.7.2 +androidx.compose.runtime:runtime:1.7.2 +androidx.compose.ui:ui-android:1.7.2 +androidx.compose.ui:ui-geometry-android:1.7.2 +androidx.compose.ui:ui-geometry:1.7.2 +androidx.compose.ui:ui-graphics-android:1.7.2 +androidx.compose.ui:ui-graphics:1.7.2 +androidx.compose.ui:ui-text-android:1.7.2 +androidx.compose.ui:ui-text:1.7.2 +androidx.compose.ui:ui-unit-android:1.7.2 +androidx.compose.ui:ui-unit:1.7.2 +androidx.compose.ui:ui-util-android:1.7.2 +androidx.compose.ui:ui-util:1.7.2 +androidx.compose:compose-bom:2024.09.02 androidx.concurrent:concurrent-futures:1.1.0 androidx.core:core-ktx:1.13.1 androidx.core:core:1.13.1 +androidx.customview:customview-poolingcontainer:1.0.0 androidx.documentfile:documentfile:1.0.0 androidx.dynamicanimation:dynamicanimation:1.0.0 +androidx.emoji2:emoji2:1.2.0 +androidx.graphics:graphics-path:1.0.1 androidx.interpolator:interpolator:1.0.0 androidx.legacy:legacy-support-core-utils:1.0.0 androidx.lifecycle:lifecycle-common-jvm:2.8.7 @@ -17,17 +40,22 @@ androidx.lifecycle:lifecycle-common:2.8.7 androidx.lifecycle:lifecycle-livedata-core-ktx:2.8.7 androidx.lifecycle:lifecycle-livedata-core:2.8.7 androidx.lifecycle:lifecycle-livedata:2.8.7 +androidx.lifecycle:lifecycle-process:2.8.7 androidx.lifecycle:lifecycle-runtime-android:2.8.7 +androidx.lifecycle:lifecycle-runtime-compose-android:2.8.7 +androidx.lifecycle:lifecycle-runtime-compose:2.8.7 androidx.lifecycle:lifecycle-runtime-ktx-android:2.8.7 androidx.lifecycle:lifecycle-runtime-ktx:2.8.7 androidx.lifecycle:lifecycle-runtime:2.8.7 androidx.lifecycle:lifecycle-viewmodel-android:2.8.7 +androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7 androidx.lifecycle:lifecycle-viewmodel-savedstate:2.8.7 androidx.lifecycle:lifecycle-viewmodel:2.8.7 androidx.loader:loader:1.0.0 androidx.localbroadcastmanager:localbroadcastmanager:1.0.0 androidx.print:print:1.0.0 androidx.profileinstaller:profileinstaller:1.3.1 +androidx.savedstate:savedstate-ktx:1.2.1 androidx.savedstate:savedstate:1.2.1 androidx.startup:startup-runtime:1.1.1 androidx.tracing:tracing:1.0.0