From cfb38a0be9a0de481a25f11275e6791e5828980d Mon Sep 17 00:00:00 2001 From: Alexey Alter-Pesotskiy Date: Fri, 5 Sep 2025 11:31:14 +0100 Subject: [PATCH 01/11] =?UTF-8?q?=E2=9A=99=EF=B8=8F=20Implement=20CI=20(#3?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/CODEOWNERS | 1 + .github/ISSUE_TEMPLATE/bug_report.md | 27 ++++ .github/ISSUE_TEMPLATE/feature_request.md | 27 ++++ .github/PULL_REQUEST_TEMPLATE.md | 36 +++++ .github/actions/bootstrap/action.yml | 18 +++ .github/actions/ruby-cache/action.yml | 9 ++ .github/actions/setup-ios-runtime/action.yml | 26 ++++ .github/actions/xcode-cache/action.yml | 16 ++ .github/stale.yml | 19 +++ .github/workflows/cron-checks.yml | 92 ++++++++++++ .github/workflows/merge-main-to-develop.yml | 32 ++++ .github/workflows/release-merge.yml | 31 ++++ .github/workflows/release-publish.yml | 25 ++++ .github/workflows/release-start.yml | 32 ++++ .github/workflows/smoke-checks.yml | 110 ++++++++++++++ .github/workflows/sonar.yml | 54 +++++++ .github/workflows/update-copyright.yml | 32 ++++ .slather.yml | 13 ++ Dangerfile | 46 ++++++ Gemfile | 14 +- Gemfile.lock | 136 ++++++++++++++--- Githubfile | 6 + Scripts/bootstrap.sh | 67 +++++++++ Scripts/install_ios_runtime.sh | 59 ++++++++ .../Utils/Publisher+AsyncStream.swift | 1 - .../StreamCore/Utils/StreamConcurrency.swift | 6 +- .../Utils/SystemEnvironment+Version.swift | 10 ++ Sources/StreamCore/Utils/Task+Timeout.swift | 1 - Sources/StreamCore/Utils/TimerPublisher.swift | 1 - Sources/StreamCore/Utils/Timers.swift | 2 +- .../Client/BackgroundTaskScheduler.swift | 12 +- Sources/StreamCoreUI/CDN/StreamCDN.swift | 4 +- .../VideoLoading/VideoLoading.swift | 14 +- StreamCore.xcodeproj/project.pbxproj | 44 +++--- .../xcschemes/StreamCore.xcscheme | 9 +- .../xcschemes/StreamCoreUI.xcscheme | 85 +++++++++++ .../Mocks/EventBatcher_Mock.swift | 4 +- Tests/StreamCoreTests/StreamCore.xctestplan | 25 ++++ .../TestUtils/VirtualTime/VirtualTimer.swift | 5 +- .../Utils/StringExtensions_Tests.swift | 1 - .../CDN/StreamImageCDN_Tests.swift | 9 +- .../StreamCoreUITests/StreamCoreUI.xctestplan | 24 +++ .../StreamCoreUITests-Bridging-Header.h | 4 - fastlane/Fastfile | 141 ++++++++++++++++++ fastlane/Pluginfile | 2 +- fastlane/Sonarfile | 18 +++ sonar-project.properties | 14 ++ 47 files changed, 1282 insertions(+), 82 deletions(-) create mode 100755 .github/CODEOWNERS create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/actions/bootstrap/action.yml create mode 100644 .github/actions/ruby-cache/action.yml create mode 100644 .github/actions/setup-ios-runtime/action.yml create mode 100644 .github/actions/xcode-cache/action.yml create mode 100644 .github/stale.yml create mode 100644 .github/workflows/cron-checks.yml create mode 100644 .github/workflows/merge-main-to-develop.yml create mode 100644 .github/workflows/release-merge.yml create mode 100644 .github/workflows/release-publish.yml create mode 100644 .github/workflows/release-start.yml create mode 100644 .github/workflows/smoke-checks.yml create mode 100644 .github/workflows/sonar.yml create mode 100644 .github/workflows/update-copyright.yml create mode 100644 .slather.yml create mode 100644 Dangerfile create mode 100644 Githubfile create mode 100755 Scripts/bootstrap.sh create mode 100755 Scripts/install_ios_runtime.sh create mode 100644 Sources/StreamCore/Utils/SystemEnvironment+Version.swift create mode 100644 StreamCore.xcodeproj/xcshareddata/xcschemes/StreamCoreUI.xcscheme create mode 100644 Tests/StreamCoreTests/StreamCore.xctestplan create mode 100644 Tests/StreamCoreUITests/StreamCoreUI.xctestplan delete mode 100644 Tests/StreamCoreUITests/StreamCoreUITests-Bridging-Header.h create mode 100755 fastlane/Sonarfile create mode 100644 sonar-project.properties diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100755 index 0000000..1617947 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @GetStream/ios-developers diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..8fc2ad4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,27 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +## What did you do? + + +## What did you expect to happen? + + +## What happened instead? + + +## GetStream Environment +**GetStream Core version:** +**GetStream Core frameworks:** StreamCore +**iOS version:** +**Swift version:** +**Xcode version:** +**Device:** + +## Additional context diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..4b42404 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,27 @@ +--- +name: Feature Request +about: Got any ideas about new features? Let us know! +title: '' +labels: '' +assignees: '' + +--- + +## What are you trying to achieve? + + +## If possible, how can you achieve this currently? + + +## What would be the better way? + + +## GetStream Environment +**GetStream Core version:** +**GetStream Core frameworks:** StreamCore +**iOS version:** +**Swift version:** +**Xcode version:** +**Device:** + +## Additional context diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..ec6884a --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,36 @@ +### 🔗 Issue Links + +_Provide all Linear and/or Github issues related to this PR, if applicable._ + +### 🎯 Goal + +_Describe why we are making this change._ + +### 📝 Summary + +_Provide bullet points with the most important changes in the codebase._ + +### 🛠 Implementation + +_Provide a detailed description of the implementation and explain your decisions if you find them relevant._ + +### 🎨 Showcase + +_Add relevant screenshots and/or videos/gifs to easily see what this PR changes, if applicable._ + +| Before | After | +| ------ | ----- | +| img | img | + +### 🧪 Manual Testing Notes + +_Explain how this change can be tested manually, if applicable._ + +### ☑️ Contributor Checklist + +- [ ] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required) +- [x] This change should be manually QAed +- [ ] Changelog is updated with client-facing changes +- [ ] Changelog is updated with new localization keys +- [ ] New code is covered by unit tests +- [ ] Documentation has been updated in the `docs-content` repo diff --git a/.github/actions/bootstrap/action.yml b/.github/actions/bootstrap/action.yml new file mode 100644 index 0000000..305195a --- /dev/null +++ b/.github/actions/bootstrap/action.yml @@ -0,0 +1,18 @@ +name: 'Bootstrap' +description: 'Run bootstrap.sh' +runs: + using: "composite" + steps: + - run: echo "IMAGE=${ImageOS}" >> $GITHUB_ENV + shell: bash + - name: Cache Mint + uses: actions/cache@v4 + id: mint-cache + with: + path: ~/.mint + key: ${{ env.IMAGE }}-mint-${{ hashFiles('**/Mintfile') }} + restore-keys: ${{ env.IMAGE }}-mint- + - uses: ./.github/actions/ruby-cache + - uses: ./.github/actions/xcode-cache + - run: ./Scripts/bootstrap.sh + shell: bash diff --git a/.github/actions/ruby-cache/action.yml b/.github/actions/ruby-cache/action.yml new file mode 100644 index 0000000..b058f27 --- /dev/null +++ b/.github/actions/ruby-cache/action.yml @@ -0,0 +1,9 @@ +name: 'Ruby Cache' +description: 'Cache Ruby dependencies' +runs: + using: "composite" + steps: + - uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.3.4 + bundler-cache: true diff --git a/.github/actions/setup-ios-runtime/action.yml b/.github/actions/setup-ios-runtime/action.yml new file mode 100644 index 0000000..d54e78f --- /dev/null +++ b/.github/actions/setup-ios-runtime/action.yml @@ -0,0 +1,26 @@ +name: 'Setup iOS Runtime' +description: 'Download and Install requested iOS Runtime' +runs: + using: "composite" + steps: + - name: Setup iOS Simulator Runtime + shell: bash + run: | + sudo rm -rfv ~/Library/Developer/CoreSimulator/* || true + bundle exec fastlane install_runtime ios:${{ inputs.version }} + sudo rm -rfv *.dmg || true + xcrun simctl list runtimes + - name: Create Custom iOS Simulator + shell: bash + run: | + ios_version_dash=$(echo "${{ inputs.version }}" | tr '.' '-') # ex: 16.4 -> 16-4 + xcrun simctl create custom-test-device "${{ inputs.device }}" "com.apple.CoreSimulator.SimRuntime.iOS-$ios_version_dash" + xcrun simctl list devices ${{ inputs.version }} + +inputs: + version: + description: "iOS Runtime Version" + required: true + device: + description: "iOS Simulator Model" + required: true diff --git a/.github/actions/xcode-cache/action.yml b/.github/actions/xcode-cache/action.yml new file mode 100644 index 0000000..92e9488 --- /dev/null +++ b/.github/actions/xcode-cache/action.yml @@ -0,0 +1,16 @@ +name: 'Xcode Cache' +description: 'Cache Xcode dependencies' +runs: + using: "composite" + steps: + - run: echo "IMAGE=${ImageOS}-${ImageVersion}" >> $GITHUB_ENV + shell: bash + - run: echo "$HOME/.mint/bin" >> $GITHUB_PATH + shell: bash + - name: Cache SPM + uses: actions/cache@v4 + id: spm-cache + with: + path: spm_cache + key: ${{ env.IMAGE }}-spm-${{ hashFiles('**/Package.resolved') }} + restore-keys: ${{ env.IMAGE }}-spm- diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 0000000..45876e0 --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,19 @@ +# Number of days of inactivity before an issue becomes stale +daysUntilStale: 20 +# Number of days of inactivity before a stale issue is closed +daysUntilClose: 7 +# Issues with these labels will never be considered stale +exemptLabels: + - pinned + - !important +# Label to use when marking an issue as stale +staleLabel: stale +# Comment to post when marking an issue as stale. Set to `false` to disable +markComment: > + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. Thank you + for your contributions. +# Comment to post when closing a stale issue. Set to `false` to disable +closeComment: > + This issue has been automatically closed due to inactivity. + Please open a new issue if it's still valid. \ No newline at end of file diff --git a/.github/workflows/cron-checks.yml b/.github/workflows/cron-checks.yml new file mode 100644 index 0000000..a68863e --- /dev/null +++ b/.github/workflows/cron-checks.yml @@ -0,0 +1,92 @@ +name: Cron Checks + +on: + schedule: + # Runs "At 04:00 every night except weekends" + - cron: '0 4 * * 1-5' + + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + HOMEBREW_NO_INSTALL_CLEANUP: 1 # Disable cleanup for homebrew, we don't need it on CI + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + +jobs: + build-and-test: + name: Test LLC + strategy: + matrix: + include: + - ios: "18.5" + device: "iPhone 16 Pro" + setup_runtime: false + - ios: "17.5" + device: "iPhone 15 Pro" + setup_runtime: true + - ios: "16.4" + device: "iPhone 14 Pro" + setup_runtime: true + - ios: "15.5" + device: "iPhone 13 Pro" + setup_runtime: true + fail-fast: false + runs-on: macos-15 + env: + XCODE_VERSION: "16.4" + steps: + - uses: actions/checkout@v4.1.1 + - uses: ./.github/actions/bootstrap + env: + INSTALL_YEETD: true + INSTALL_IPSW: true + - uses: ./.github/actions/setup-ios-runtime + if: ${{ matrix.setup_runtime }} + timeout-minutes: 60 + with: + version: ${{ matrix.ios }} + device: ${{ matrix.device }} + - name: Run LLC Tests (Debug) + run: bundle exec fastlane test device:"${{ matrix.device }} (${{ matrix.ios }})" cron:true + timeout-minutes: 60 + - name: Parse xcresult + if: failure() + run: | + brew install chargepoint/xcparse/xcparse + xcparse logs fastlane/test_output/StreamCore.xcresult fastlane/test_output/logs/ + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: Test Data LLC (iOS ${{ matrix.ios }}) + path: | + fastlane/test_output/logs/*/Diagnostics/**/*.txt + fastlane/test_output/logs/*/Diagnostics/simctl_diagnostics/DiagnosticReports/* + + automated-code-review: + name: Automated Code Review + runs-on: macos-15 + env: + XCODE_VERSION: "16.0" + steps: + - uses: actions/checkout@v4.1.1 + - uses: ./.github/actions/bootstrap + - run: bundle exec fastlane rubocop + - run: bundle exec fastlane run_swift_format strict:true + + slack: + name: Slack Report + runs-on: ubuntu-latest + needs: [build-and-test, automated-code-review] + if: failure() && github.event_name == 'schedule' + steps: + - uses: 8398a7/action-slack@v3 + with: + status: cancelled + text: "You shall not pass!" + job_name: "${{ github.workflow }}: ${{ github.job }}" + fields: repo,commit,author,workflow + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_NIGHTLY_CHECKS }} diff --git a/.github/workflows/merge-main-to-develop.yml b/.github/workflows/merge-main-to-develop.yml new file mode 100644 index 0000000..43b7da2 --- /dev/null +++ b/.github/workflows/merge-main-to-develop.yml @@ -0,0 +1,32 @@ +name: "Merge main to develop" + +on: + workflow_dispatch: + +permissions: + contents: write + +jobs: + merge: + name: Merge + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4.1.1 + with: + token: ${{ secrets.ADMIN_API_TOKEN }} + fetch-depth: 0 + + - uses: ./.github/actions/ruby-cache + + - run: bundle exec fastlane merge_main + env: + GITHUB_TOKEN: ${{ secrets.ADMIN_API_TOKEN }} + + - uses: 8398a7/action-slack@v3 + if: failure() + with: + status: ${{ job.status }} + text: "⚠️ , the merge of `main` to `develop` failed on CI. Consider using this command locally: `bundle exec fastlane merge_main`" + fields: repo,commit,author,workflow + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/.github/workflows/release-merge.yml b/.github/workflows/release-merge.yml new file mode 100644 index 0000000..250cab8 --- /dev/null +++ b/.github/workflows/release-merge.yml @@ -0,0 +1,31 @@ +name: "Merge release" + +on: + issue_comment: + types: [created] + + workflow_dispatch: + +jobs: + merge-comment: + name: Merge release to main + runs-on: macos-15 + if: github.event_name == 'workflow_dispatch' || (github.event.issue.pull_request && github.event.issue.state == 'open' && github.event.comment.body == '/merge release') + steps: + - name: Connect Bot + uses: webfactory/ssh-agent@v0.7.0 + with: + ssh-private-key: ${{ secrets.BOT_SSH_PRIVATE_KEY }} + + - uses: actions/checkout@v4.1.1 + with: + fetch-depth: 0 + + - uses: ./.github/actions/ruby-cache + + - name: Merge + run: bundle exec fastlane merge_release author:"$USER_LOGIN" --verbose + env: + GITHUB_TOKEN: ${{ secrets.ADMIN_API_TOKEN }} # A token with the "admin:org" scope to get the list of the team members on GitHub + GITHUB_PR_NUM: ${{ github.event.issue.number }} + USER_LOGIN: ${{ github.event.comment.user.login != null && github.event.comment.user.login || github.event.sender.login }} diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml new file mode 100644 index 0000000..5ed3cca --- /dev/null +++ b/.github/workflows/release-publish.yml @@ -0,0 +1,25 @@ +name: "Publish new release" + +on: + workflow_dispatch: + +jobs: + release: + name: Publish new release + runs-on: macos-15 + steps: + - name: Connect Bot + uses: webfactory/ssh-agent@v0.7.0 + with: + ssh-private-key: ${{ secrets.BOT_SSH_PRIVATE_KEY }} + + - uses: actions/checkout@v4.1.1 + with: + fetch-depth: 0 + + - uses: ./.github/actions/ruby-cache + + - name: "Fastlane - Publish Release" + env: + GITHUB_TOKEN: ${{ secrets.CI_BOT_GITHUB_TOKEN }} + run: bundle exec fastlane publish_release --verbose diff --git a/.github/workflows/release-start.yml b/.github/workflows/release-start.yml new file mode 100644 index 0000000..484be68 --- /dev/null +++ b/.github/workflows/release-start.yml @@ -0,0 +1,32 @@ +name: "Start new release" + +on: + workflow_dispatch: + inputs: + version: + description: 'Release version' + type: string + required: true + +jobs: + test-release: + name: Start new release + runs-on: macos-15 + steps: + - name: Connect Bot + uses: webfactory/ssh-agent@v0.7.0 + with: + ssh-private-key: ${{ secrets.BOT_SSH_PRIVATE_KEY }} + + - uses: actions/checkout@v4.1.1 + with: + fetch-depth: 0 # to fetch git tags + + - uses: ./.github/actions/ruby-cache + + - uses: ./.github/actions/xcode-cache + + - name: Create Release PR + run: bundle exec fastlane release version:"${{ github.event.inputs.version }}" --verbose + env: + GITHUB_TOKEN: ${{ secrets.CI_BOT_GITHUB_TOKEN }} diff --git a/.github/workflows/smoke-checks.yml b/.github/workflows/smoke-checks.yml new file mode 100644 index 0000000..1e02f93 --- /dev/null +++ b/.github/workflows/smoke-checks.yml @@ -0,0 +1,110 @@ +name: Smoke Checks + +on: + pull_request: + paths-ignore: + - 'README.md' + - 'CHANGELOG.md' + + workflow_dispatch: + inputs: + record_snapshots: + description: 'Record snapshots on CI?' + type: boolean + required: false + default: false + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + HOMEBREW_NO_INSTALL_CLEANUP: 1 # Disable cleanup for homebrew, we don't need it on CI + IOS_SIMULATOR_DEVICE: "iPhone 16 Pro (18.5)" + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_PR_NUM: ${{ github.event.pull_request.number }} + +jobs: + test_llc: + name: Test LLC + runs-on: macos-15 + steps: + - uses: actions/checkout@v4.1.1 + with: + fetch-depth: 0 + - uses: ./.github/actions/bootstrap + env: + INSTALL_YEETD: true + INSTALL_SONAR: true + - name: Run LLC Tests (Debug) + run: bundle exec fastlane test device:"${{ env.IOS_SIMULATOR_DEVICE }}" + timeout-minutes: 40 + - name: Run Sonar analysis + run: bundle exec fastlane sonar_upload + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + - uses: 8398a7/action-slack@v3 + with: + status: ${{ job.status }} + text: "You shall not pass!" + job_name: "Test LLC (Debug)" + fields: repo,commit,author,workflow + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + if: ${{ github.event_name == 'push' && failure() }} + - name: Parse xcresult + if: failure() + run: | + brew install chargepoint/xcparse/xcparse + xcparse logs fastlane/test_output/StreamCore.xcresult fastlane/test_output/logs/ + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: Test Data LLC + path: | + fastlane/test_output/logs/*/Diagnostics/**/*.txt + fastlane/test_output/logs/*/Diagnostics/simctl_diagnostics/DiagnosticReports/* + - name: Upload Test Coverage + uses: actions/upload-artifact@v4 + with: + name: test-coverage-${{ github.event.pull_request.number }} + path: reports/sonarqube-generic-coverage.xml + + + test_ui: + name: Test UI + runs-on: macos-15 + steps: + - uses: actions/checkout@v4.1.1 + with: + fetch-depth: 0 + - uses: ./.github/actions/bootstrap + env: + INSTALL_YEETD: true + - name: Run UI Tests (Debug) + run: bundle exec fastlane test_ui device:"${{ env.IOS_SIMULATOR_DEVICE }}" + timeout-minutes: 40 + - uses: 8398a7/action-slack@v3 + with: + status: ${{ job.status }} + text: "You shall not pass!" + job_name: "Test LLC (Debug)" + fields: repo,commit,author,workflow + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + if: ${{ github.event_name == 'push' && failure() }} + + automated-code-review: + name: Automated Code Review + runs-on: macos-15 + env: + XCODE_VERSION: "16.0" + steps: + - uses: actions/checkout@v4.1.1 + - uses: ./.github/actions/bootstrap + env: + INSTALL_INTERFACE_ANALYZER: true + - run: bundle exec fastlane lint_pr + - run: bundle exec fastlane rubocop + - run: bundle exec fastlane run_swift_format strict:true diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml new file mode 100644 index 0000000..73d5ac2 --- /dev/null +++ b/.github/workflows/sonar.yml @@ -0,0 +1,54 @@ +name: Sonar + +on: + push: + branches: + - develop + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + +jobs: + sonar: + runs-on: macos-15 + steps: + - uses: actions/checkout@v4.1.1 + + - uses: ./.github/actions/bootstrap + env: + INSTALL_SONAR: true + SKIP_MINT_BOOTSTRAP: true + + - uses: actions/github-script@v6 + id: get_pr_number + with: + script: | + const prs = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + commit_sha: context.sha, + owner: context.repo.owner, + repo: context.repo.repo, + }); + return prs.data[0]?.number || ''; + + - name: Run Sonar analysis + run: | + if [[ -z "${{ steps.get_pr_number.outputs.result }}" ]]; then + echo "No PR found. Skipping Sonar analysis." + exit 0 + fi + + ARTIFACT_NAME="test-coverage-${{ steps.get_pr_number.outputs.result }}" + ARTIFACT=$(gh api repos/${{ github.repository }}/actions/artifacts | jq -r ".artifacts | map(select(.name==\"$ARTIFACT_NAME\")) | first") + if [[ "$ARTIFACT" == null || "$ARTIFACT" == "" ]]; then + echo "Artifact not found. Skipping Sonar analysis." + else + gh run download $(echo $ARTIFACT | jq .workflow_run.id) -n "$ARTIFACT_NAME" -D reports + bundle exec fastlane sonar_upload + fi + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/update-copyright.yml b/.github/workflows/update-copyright.yml new file mode 100644 index 0000000..bfa0799 --- /dev/null +++ b/.github/workflows/update-copyright.yml @@ -0,0 +1,32 @@ +name: Copyright + +on: + schedule: + # Runs "At 08:00 on day-of-month 1 in January" + - cron: '0 8 1 1 *' + + workflow_dispatch: + +env: + HOMEBREW_NO_INSTALL_CLEANUP: 1 # Disable cleanup for homebrew, we don't need it on CI + +jobs: + copyright: + name: Copyright + runs-on: macos-15 + steps: + - uses: actions/checkout@v4.1.1 + - uses: ./.github/actions/ruby-cache + - run: bundle exec fastlane copyright + timeout-minutes: 5 + env: + GITHUB_TOKEN: ${{ secrets.CI_BOT_GITHUB_TOKEN }} + - uses: 8398a7/action-slack@v3 + with: + status: ${{ job.status }} + text: "You shall not pass!" + job_name: "${{ github.workflow }}: ${{ github.job }}" + fields: repo,commit,author,workflow + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + if: failure() diff --git a/.slather.yml b/.slather.yml new file mode 100644 index 0000000..3347d0f --- /dev/null +++ b/.slather.yml @@ -0,0 +1,13 @@ +coverage_service: sonarqube_xml +xcodeproj: StreamCore.xcodeproj +scheme: StreamCore +configuration: Debug +build_directory: derived_data/ +source_directory: Sources/ +output_directory: reports +ignore: + - "**/*_Vendor.swift" + - "**/Generated/" + - "**/generated/" + - "**/protobuf/" + - "**/OpenApi/" diff --git a/Dangerfile b/Dangerfile new file mode 100644 index 0000000..d540119 --- /dev/null +++ b/Dangerfile @@ -0,0 +1,46 @@ +pr_body = github.pr_body +pr_labels = github.pr_labels + +if pr_body.include?("#skip_danger") + message("Skipping Danger due to skip_danger tag") + return +end + +# Make it more obvious that a PR is a work in progress and shouldn't be merged yet +has_wip_labels = pr_labels.any? { |label| label =~ /(WIP|Help Wanted)/ } +if github.pr_json.draft || has_wip_labels + message("Skipping Danger since the Pull Request is classed as `Draft`/`Work In Progress`") + return +end + +# Don't forget to tick the required checkboxes in a PR description +missed_checkboxes = pr_body.each_line.any? { |line| line.include?("[ ]") && line.include?("(required)") } +warn("Please be sure to complete the `Contributor Checklist` in the Pull Request description") if missed_checkboxes + +# Warn when there is a big PR. +warn("Big PR") if git.lines_of_code > 500 + +# Mainly to encourage writing up some reasoning about the PR, rather than just leaving a title. +warn("Please provide a summary in the Pull Request description") if pr_body.length < 3 && git.lines_of_code > 50 + +# Add a CHANGELOG entry for app changes +has_changelog_escape_labels = pr_labels.any? { |label| label =~ /(Meta|Demo App)/ } +has_changelog_escape_tags = pr_body =~ /(#no_changelog|#skip_changelog)/ +has_app_changes = !git.modified_files.grep(/Sources/).empty? +has_changelog_changes = !git.modified_files.include?("CHANGELOG.md") +if !has_changelog_escape_labels && !has_changelog_escape_tags && has_changelog_changes && has_app_changes + message("There seems to be app changes but CHANGELOG wasn't modified." \ + "\nPlease include an entry if the PR includes user-facing changes." \ + "\nYou can find it at [CHANGELOG.md](https://github.com/#{ENV['GITHUB_REPOSITORY']}/blob/main/CHANGELOG.md).") +end + +# Make it clear that a PR is ready for QA and needs to be picked up by someone to test the changes +has_ticked_qa_checkbox = pr_body.include?("[x] This PR should be manually QAed") +has_ready_for_qa_label = pr_labels.any? { |label| label.include?("Ready For QA") } +has_qaed_label = pr_labels.any? { |label| label.include?("QAed") } +if !has_qaed_label && (has_ready_for_qa_label || has_ticked_qa_checkbox) + warn("The changes should be manually QAed before the Pull Request will be merged") +end + +# Check all commits have correct format. Disable the length rule, since it's hardcoded to 50 and GitHub has the limit 80 +commit_lint.check(disable: [:subject_length]) diff --git a/Gemfile b/Gemfile index c340e8c..ecedb6f 100644 --- a/Gemfile +++ b/Gemfile @@ -4,14 +4,26 @@ source 'https://rubygems.org' git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } -gem 'fastlane' +gem 'danger', group: :danger_dependencies +gem 'fastlane', group: :fastlane_dependencies gem 'json' gem 'lefthook' gem 'rubocop', '1.38', group: :rubocop_dependencies +gem 'slather' eval_gemfile('fastlane/Pluginfile') +group :fastlane_dependencies do + gem 'fastlane-plugin-lizard' + gem 'plist' + gem 'xctest_list' +end + group :rubocop_dependencies do gem 'rubocop-performance' gem 'rubocop-require_tools' end + +group :danger_dependencies do + gem 'danger-commit_lint' +end diff --git a/Gemfile.lock b/Gemfile.lock index f6096d7..95982e3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -5,41 +5,86 @@ GEM base64 nkf rexml + activesupport (8.0.2.1) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) artifactory (3.0.17) ast (2.4.3) atomos (0.1.3) aws-eventstream (1.4.0) - aws-partitions (1.1117.0) - aws-sdk-core (3.226.0) + aws-partitions (1.1154.0) + aws-sdk-core (3.232.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) base64 + bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.105.0) - aws-sdk-core (~> 3, >= 3.225.0) + aws-sdk-kms (1.112.0) + aws-sdk-core (~> 3, >= 3.231.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.189.1) - aws-sdk-core (~> 3, >= 3.225.0) + aws-sdk-s3 (1.198.0) + aws-sdk-core (~> 3, >= 3.231.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) base64 (0.3.0) + benchmark (0.4.1) + bigdecimal (3.2.3) claide (1.1.0) + claide-plugins (0.9.2) + cork + nap + open4 (~> 1.3) + clamp (1.3.3) + coderay (1.1.3) colored (1.2) colored2 (3.1.2) commander (4.6.0) highline (~> 2.0.0) + concurrent-ruby (1.3.5) + connection_pool (2.5.4) + cork (0.3.0) + colored2 (~> 3.1) + danger (9.5.3) + base64 (~> 0.2) + claide (~> 1.0) + claide-plugins (>= 0.9.2) + colored2 (>= 3.1, < 5) + cork (~> 0.1) + faraday (>= 0.9.0, < 3.0) + faraday-http-cache (~> 2.0) + git (>= 1.13, < 3.0) + kramdown (>= 2.5.1, < 3.0) + kramdown-parser-gfm (~> 1.0) + octokit (>= 4.0) + pstore (~> 0.1) + terminal-table (>= 1, < 5) + danger-commit_lint (0.0.7) + danger-plugin-api (~> 1.0) + danger-plugin-api (1.0.0) + danger (> 2.0) declarative (0.0.20) digest-crc (0.7.0) rake (>= 12.0.0, < 14.0.0) domain_name (0.6.20240107) dotenv (2.8.1) + drb (2.2.3) emoji_regex (3.2.3) excon (0.112.0) faraday (1.10.4) @@ -60,6 +105,8 @@ GEM faraday-em_http (1.0.0) faraday-em_synchrony (1.0.1) faraday-excon (1.1.0) + faraday-http-cache (2.5.1) + faraday (>= 0.8) faraday-httpclient (1.0.1) faraday-multipart (1.1.1) multipart-post (~> 2.0) @@ -113,12 +160,21 @@ GEM xcodeproj (>= 1.13.0, < 2.0.0) xcpretty (~> 0.4.1) xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) - fastlane-plugin-stream_actions (0.3.87) + fastlane-plugin-lizard (1.3.3) + bundler + fastlane + pry + fastlane-plugin-stream_actions (0.3.90) xctest_list (= 1.2.1) fastlane-plugin-versioning (0.7.1) fastlane-sirp (1.0.0) sysrandom (~> 1.0) gh_inspector (1.1.3) + git (2.3.3) + activesupport (>= 5.0) + addressable (~> 2.8) + process_executer (~> 1.1) + rchardet (~> 1.8) google-apis-androidpublisher_v3 (0.54.0) google-apis-core (>= 0.11.0, < 2.a) google-apis-core (0.11.3) @@ -160,39 +216,60 @@ GEM domain_name (~> 0.5) httpclient (2.9.0) mutex_m + i18n (1.14.7) + concurrent-ruby (~> 1.0) jmespath (1.6.2) - json (2.12.2) - jwt (2.10.1) + json (2.13.2) + jwt (2.10.2) base64 - lefthook (1.11.16) + kramdown (2.5.1) + rexml (>= 3.3.9) + kramdown-parser-gfm (1.1.0) + kramdown (~> 2.0) + lefthook (1.12.3) logger (1.7.0) + method_source (1.1.0) mini_magick (4.13.2) mini_mime (1.1.5) - multi_json (1.15.0) + minitest (5.25.5) + multi_json (1.17.0) multipart-post (2.4.1) mutex_m (0.3.0) nanaimo (0.4.0) + nap (1.1.0) naturally (2.3.0) nkf (0.2.0) + nokogiri (1.18.9-arm64-darwin) + racc (~> 1.4) + octokit (10.0.0) + faraday (>= 1, < 3) + sawyer (~> 0.9) + open4 (1.3.4) optparse (0.6.0) os (1.1.4) parallel (1.27.0) - parser (3.3.8.0) + parser (3.3.9.0) ast (~> 2.4.1) racc plist (3.7.2) prism (1.4.0) - public_suffix (4.0.7) + process_executer (1.3.0) + pry (0.15.2) + coderay (~> 1.1) + method_source (~> 1.0) + pstore (0.2.0) + public_suffix (6.0.2) racc (1.8.1) rainbow (3.1.1) rake (13.3.0) - regexp_parser (2.10.0) + rchardet (1.9.0) + regexp_parser (2.11.2) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) retriable (3.1.2) - rexml (3.4.1) + rexml (3.4.2) rouge (3.28.0) rubocop (1.38.0) json (~> 2.3) @@ -204,7 +281,7 @@ GEM rubocop-ast (>= 1.23.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 1.4.0, < 3.0) - rubocop-ast (1.45.1) + rubocop-ast (1.46.0) parser (>= 3.3.7.2) prism (~> 1.4) rubocop-performance (1.19.1) @@ -215,15 +292,25 @@ GEM ruby-progressbar (1.13.0) ruby2_keywords (0.0.5) rubyzip (2.4.1) + sawyer (0.9.2) + addressable (>= 2.3.5) + faraday (>= 0.17.3, < 3) + securerandom (0.4.1) security (0.1.5) - signet (0.20.0) + signet (0.21.0) addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) - jwt (>= 1.5, < 3.0) + jwt (>= 1.5, < 4.0) multi_json (~> 1.10) simctl (1.6.10) CFPropertyList naturally + slather (2.8.5) + CFPropertyList (>= 2.2, < 4) + activesupport + clamp (~> 1.3) + nokogiri (>= 1.14.3) + xcodeproj (~> 1.27) sysrandom (1.0.5) terminal-notifier (2.0.0) terminal-table (3.0.2) @@ -233,8 +320,11 @@ GEM tty-screen (0.8.2) tty-spinner (0.9.3) tty-cursor (~> 0.7) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) uber (0.1.0) unicode-display_width (2.6.0) + uri (1.0.3) word_wrap (1.0.0) xcodeproj (1.27.0) CFPropertyList (>= 2.3.3, < 4.0) @@ -250,17 +340,23 @@ GEM xctest_list (1.2.1) PLATFORMS - ruby + arm64-darwin-23 DEPENDENCIES + danger + danger-commit_lint fastlane - fastlane-plugin-stream_actions (= 0.3.87) + fastlane-plugin-lizard + fastlane-plugin-stream_actions (= 0.3.90) fastlane-plugin-versioning json lefthook + plist rubocop (= 1.38) rubocop-performance rubocop-require_tools + slather + xctest_list BUNDLED WITH 2.5.17 diff --git a/Githubfile b/Githubfile new file mode 100644 index 0000000..2baa2a4 --- /dev/null +++ b/Githubfile @@ -0,0 +1,6 @@ +#!/bin/bash + +export YEETD_VERSION='1.0' +export MINT_VERSION='0.17.5' +export SONAR_VERSION='7.2.0.5079' +export IPSW_VERSION='3.1.592' diff --git a/Scripts/bootstrap.sh b/Scripts/bootstrap.sh new file mode 100755 index 0000000..b488153 --- /dev/null +++ b/Scripts/bootstrap.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# shellcheck source=/dev/null +# Usage: ./bootstrap.sh +# This script will: +# - install Mint and bootstrap its dependencies +# - link git hooks +# - install sonar-scanner if `INSTALL_SONAR` environment variable is provided +# If you get `zsh: permission denied: ./bootstrap.sh` error, please run `chmod +x bootstrap.sh` first + +function puts { + echo + echo -e "👉 ${1}" +} + +# Set bash to Strict Mode (http://redsymbol.net/articles/unofficial-bash-strict-mode/) +set -Eeuo pipefail + +trap "echo ; echo ❌ The Bootstrap script failed to finish without error. See the log above to debug. ; echo" ERR + +source ./Githubfile + +if [ "${GITHUB_ACTIONS:-}" != "true" ]; then + puts "Set up git hooks" + bundle install + bundle exec lefthook install +fi + +if [ "${SKIP_MINT_BOOTSTRAP:-}" != true ]; then + puts "Bootstrap Mint dependencies" + git clone https://github.com/yonaskolb/Mint.git fastlane/mint + root=$(pwd) + cd fastlane/mint + swift run mint install "yonaskolb/mint@${MINT_VERSION}" + cd $root + rm -rf fastlane/mint + mint bootstrap --link +fi + +if [[ ${INSTALL_SONAR-default} == true ]]; then + puts "Install sonar scanner" + DOWNLOAD_URL="https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-${SONAR_VERSION}-macosx-x64.zip" + curl -sL "${DOWNLOAD_URL}" -o ./fastlane/sonar.zip + cd fastlane + unzip sonar.zip + rm sonar.zip + cd .. + mv "fastlane/sonar-scanner-${SONAR_VERSION}-macosx-x64/" fastlane/sonar/ + chmod +x ./fastlane/sonar/bin/sonar-scanner +fi + +if [[ ${INSTALL_YEETD-default} == true ]]; then + PACKAGE="yeetd-normal.pkg" + puts "Install yeetd v${YEETD_VERSION}" + wget "https://github.com/biscuitehh/yeetd/releases/download/${YEETD_VERSION}/${PACKAGE}" + sudo installer -pkg ${PACKAGE} -target / + puts "Running yeetd daemon" + yeetd & +fi + +if [[ ${INSTALL_IPSW-default} == true ]]; then + puts "Install ipsw v${IPSW_VERSION}" + FILE="ipsw_${IPSW_VERSION}_macOS_universal.tar.gz" + wget "https://github.com/blacktop/ipsw/releases/download/v${IPSW_VERSION}/${FILE}" + tar -xzf "$FILE" + chmod +x ipsw + sudo mv ipsw /usr/local/bin/ +fi diff --git a/Scripts/install_ios_runtime.sh b/Scripts/install_ios_runtime.sh new file mode 100755 index 0000000..8fd0344 --- /dev/null +++ b/Scripts/install_ios_runtime.sh @@ -0,0 +1,59 @@ +#!/bin/bash -e +# Copyright 2024 Namespace Labs Inc. Licensed under the MIT License. + +log() { echo "👉 ${1}" >&2; } +die() { log "${1}"; exit 1; } +[ $# -eq 1 ] || die "usage: $0 path/to/runtime.dmg" + +dmg=$1 +mountpoint=$(mktemp -d) +staging=$(mktemp -d) + +cleanup() { + if [ -d "$staging" ]; then + set +e + log "Removing $staging..." + rm -r "$staging" || true + log "Unmounting $mountpoint..." + hdiutil detach "$mountpoint" >&2 || true + fi + + if [ -d "$mountpoint" ]; then + log "Removing $mountpoint..." + rmdir "$mountpoint" || true + fi +} +trap cleanup EXIT + +log "Mounting $dmg on $mountpoint..." +hdiutil attach "$dmg" -mountpoint "$mountpoint" >&2 + +if ! ls "$mountpoint"/*.pkg >/dev/null 2>&1; then + log "Detected a modern volume runtime; installing with simctl..." + xcrun simctl runtime add "$1" + exit 0 +fi + +log "Detected packaged runtime." + +bundle=$(echo "$mountpoint"/*.pkg) +basename=$(basename "$bundle") +sdkname=${basename%.*} +log "Found package $bundle (sdk $sdkname)." + +log "Expanding package $bundle to $staging/expanded..." +pkgutil --expand "$bundle" "$staging/expanded" + +dest=/Library/Developer/CoreSimulator/Profiles/Runtimes/$sdkname.simruntime +# The package would try to install itself into volume root; this is wrong. +log "Rewriting package install location to $dest..." +sed -I '' "s|(_ action: @MainActor @Sendable() throws -> T) rethrows -> T where T: Sendable { + public static func onMain(_ action: @MainActor @Sendable () throws -> T) rethrows -> T where T: Sendable { if Thread.current.isMainThread { - return try MainActor.assumeIsolated { + try MainActor.assumeIsolated { try action() } } else { // We use sync here, because this function supports returning a value. - return try DispatchQueue.main.sync { + try DispatchQueue.main.sync { try action() } } diff --git a/Sources/StreamCore/Utils/SystemEnvironment+Version.swift b/Sources/StreamCore/Utils/SystemEnvironment+Version.swift new file mode 100644 index 0000000..8081964 --- /dev/null +++ b/Sources/StreamCore/Utils/SystemEnvironment+Version.swift @@ -0,0 +1,10 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +enum SystemEnvironment { + /// A Stream Core version. + public static let version: String = "0.1.0-SNAPSHOT" +} diff --git a/Sources/StreamCore/Utils/Task+Timeout.swift b/Sources/StreamCore/Utils/Task+Timeout.swift index f86ed10..d913e22 100644 --- a/Sources/StreamCore/Utils/Task+Timeout.swift +++ b/Sources/StreamCore/Utils/Task+Timeout.swift @@ -18,7 +18,6 @@ extension Task where Failure == any Error { ) { self = Task(priority: priority) { try await withThrowingTaskGroup(of: Success.self) { group in - /// Add the operation to perform as the first task. _ = group.addTaskUnlessCancelled { try await operation() diff --git a/Sources/StreamCore/Utils/TimerPublisher.swift b/Sources/StreamCore/Utils/TimerPublisher.swift index e9f98e1..774a7e4 100644 --- a/Sources/StreamCore/Utils/TimerPublisher.swift +++ b/Sources/StreamCore/Utils/TimerPublisher.swift @@ -36,7 +36,6 @@ final class TimerPublisher: Publisher { } extension TimerPublisher { - /// A subscription wrapper that handles timer events and lifecycle. /// /// Emits `Date` values to the subscriber while the timer is active. It diff --git a/Sources/StreamCore/Utils/Timers.swift b/Sources/StreamCore/Utils/Timers.swift index 91f7e09..9e3d480 100644 --- a/Sources/StreamCore/Utils/Timers.swift +++ b/Sources/StreamCore/Utils/Timers.swift @@ -2,8 +2,8 @@ // Copyright © 2025 Stream.io Inc. All rights reserved. // -import Foundation import Combine +import Foundation public protocol Timer { /// Schedules a new timer. diff --git a/Sources/StreamCore/WebSocket/Client/BackgroundTaskScheduler.swift b/Sources/StreamCore/WebSocket/Client/BackgroundTaskScheduler.swift index fad6fbf..ea82c65 100644 --- a/Sources/StreamCore/WebSocket/Client/BackgroundTaskScheduler.swift +++ b/Sources/StreamCore/WebSocket/Client/BackgroundTaskScheduler.swift @@ -62,8 +62,8 @@ public class IOSBackgroundTaskScheduler: BackgroundTaskScheduler, @unchecked Sen } } - @MainActor private var onEnteringBackground: () -> Void = {} - @MainActor private var onEnteringForeground: () -> Void = {} + private var onEnteringBackground: () -> Void = {} + private var onEnteringForeground: () -> Void = {} public func startListeningForAppStateUpdates( onEnteringBackground: @escaping () -> Void, @@ -105,15 +105,11 @@ public class IOSBackgroundTaskScheduler: BackgroundTaskScheduler, @unchecked Sen } @objc private func handleAppDidEnterBackground() { - Task { @MainActor in - onEnteringBackground() - } + onEnteringBackground() } @objc private func handleAppDidBecomeActive() { - Task { @MainActor in - onEnteringForeground() - } + onEnteringForeground() } deinit { diff --git a/Sources/StreamCoreUI/CDN/StreamCDN.swift b/Sources/StreamCoreUI/CDN/StreamCDN.swift index a264e54..1825868 100644 --- a/Sources/StreamCoreUI/CDN/StreamCDN.swift +++ b/Sources/StreamCoreUI/CDN/StreamCDN.swift @@ -5,7 +5,7 @@ import UIKit open class StreamImageCDN: ImageCDN, @unchecked Sendable { - nonisolated(unsafe) public static var streamCDNURL = "stream-io-cdn.com" + public nonisolated(unsafe) static var streamCDNURL = "stream-io-cdn.com" public init() {} @@ -17,7 +17,7 @@ open class StreamImageCDN: ImageCDN, @unchecked Sendable { } // If there is not resize, not need to add query parameters to the URL. - guard let resize = resize else { + guard let resize else { return URLRequest(url: url) } diff --git a/Sources/StreamCoreUI/VideoLoading/VideoLoading.swift b/Sources/StreamCoreUI/VideoLoading/VideoLoading.swift index 4133773..900749f 100644 --- a/Sources/StreamCoreUI/VideoLoading/VideoLoading.swift +++ b/Sources/StreamCoreUI/VideoLoading/VideoLoading.swift @@ -12,7 +12,7 @@ public protocol VideoLoading: AnyObject { /// - Parameters: /// - url: A video URL. /// - completion: A completion that is called when a preview is loaded. Must be invoked on main queue. - func loadPreviewForVideo(at url: URL, completion: @escaping @MainActor @Sendable(Result) -> Void) + func loadPreviewForVideo(at url: URL, completion: @escaping @MainActor @Sendable (Result) -> Void) /// Returns a video asset with the given URL. /// @@ -45,7 +45,7 @@ open class StreamVideoLoader: VideoLoading, @unchecked Sendable { NotificationCenter.default.removeObserver(self) } - open func loadPreviewForVideo(at url: URL, completion: @escaping @MainActor @Sendable(Result) -> Void) { + open func loadPreviewForVideo(at url: URL, completion: @escaping @MainActor @Sendable (Result) -> Void) { if let cached = cache[url] { return call(completion, with: .success(cached)) } @@ -56,20 +56,20 @@ open class StreamVideoLoader: VideoLoading, @unchecked Sendable { imageGenerator.appliesPreferredTrackTransform = true imageGenerator.generateCGImagesAsynchronously(forTimes: [.init(time: frameTime)]) { [weak self] _, image, _, _, error in - guard let self = self else { return } + guard let self else { return } let result: Result if let thumbnail = image { result = .success(.init(cgImage: thumbnail)) - } else if let error = error { + } else if let error { result = .failure(error) } else { log.error("Both error and image are `nil`.") return } - self.cache[url] = try? result.get() - self.call(completion, with: result) + cache[url] = try? result.get() + call(completion, with: result) } } @@ -77,7 +77,7 @@ open class StreamVideoLoader: VideoLoading, @unchecked Sendable { .init(url: url) } - private func call(_ completion: @escaping @MainActor @Sendable(Result) -> Void, with result: Result) { + private func call(_ completion: @escaping @MainActor @Sendable (Result) -> Void, with result: Result) { StreamConcurrency.onMain { completion(result) } diff --git a/StreamCore.xcodeproj/project.pbxproj b/StreamCore.xcodeproj/project.pbxproj index 7e7ec95..04fb545 100644 --- a/StreamCore.xcodeproj/project.pbxproj +++ b/StreamCore.xcodeproj/project.pbxproj @@ -43,6 +43,13 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 829528D02E69CD5200AED807 /* Exceptions for "Tests" folder in "StreamCoreTests" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + StreamCoreUITests/CDN/StreamImageCDN_Tests.swift, + ); + target = 845495272DBA3A1300211413 /* StreamCoreTests */; + }; 8415DA112E462E9F00FEE25F /* Exceptions for "StreamCoreUI" folder in "StreamCoreUI" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; publicHeaders = ( @@ -70,6 +77,7 @@ 4F8C954A2DF8536400996F0D /* Tests */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( + 829528D02E69CD5200AED807 /* Exceptions for "Tests" folder in "StreamCoreTests" target */, 8415DA382E4630F800FEE25F /* Exceptions for "Tests" folder in "StreamCoreUITests" target */, ); path = Tests; @@ -274,7 +282,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1620; - LastUpgradeCheck = 1620; + LastUpgradeCheck = 1640; TargetAttributes = { 8415DA0A2E462E9F00FEE25F = { CreatedOnToolsVersion = 16.2; @@ -398,10 +406,10 @@ isa = XCBuildConfiguration; buildSettings = { BUILD_LIBRARY_FOR_DISTRIBUTION = YES; + CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = EHV7XZLAHA; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -409,6 +417,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -422,7 +431,6 @@ SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_INSTALL_OBJC_HEADER = NO; - SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -431,10 +439,10 @@ isa = XCBuildConfiguration; buildSettings = { BUILD_LIBRARY_FOR_DISTRIBUTION = YES; + CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = EHV7XZLAHA; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -442,6 +450,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -455,7 +464,6 @@ SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_INSTALL_OBJC_HEADER = NO; - SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; @@ -466,15 +474,13 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = EHV7XZLAHA; GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = io.getstream.StreamCoreUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_OBJC_BRIDGING_HEADER = "Tests/StreamCoreUITests/CDN/StreamCoreUITests-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -485,14 +491,12 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = EHV7XZLAHA; GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = io.getstream.StreamCoreUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_OBJC_BRIDGING_HEADER = "Tests/StreamCoreUITests/CDN/StreamCoreUITests-Bridging-Header.h"; - SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; @@ -501,10 +505,10 @@ isa = XCBuildConfiguration; buildSettings = { BUILD_LIBRARY_FOR_DISTRIBUTION = YES; + CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = EHV7XZLAHA; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -526,7 +530,6 @@ SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_INSTALL_OBJC_HEADER = NO; - SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -535,10 +538,10 @@ isa = XCBuildConfiguration; buildSettings = { BUILD_LIBRARY_FOR_DISTRIBUTION = YES; + CODE_SIGN_IDENTITY = ""; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = EHV7XZLAHA; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -560,7 +563,6 @@ SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_INSTALL_OBJC_HEADER = NO; - SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; @@ -601,6 +603,7 @@ COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = EHV7XZLAHA; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -618,7 +621,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; @@ -668,6 +671,7 @@ COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = EHV7XZLAHA; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -679,7 +683,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; @@ -697,13 +701,12 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = EHV7XZLAHA; GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = io.getstream.StreamCoreTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -713,13 +716,12 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = EHV7XZLAHA; GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = io.getstream.StreamCoreTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; diff --git a/StreamCore.xcodeproj/xcshareddata/xcschemes/StreamCore.xcscheme b/StreamCore.xcodeproj/xcshareddata/xcschemes/StreamCore.xcscheme index abcde0e..7943a18 100644 --- a/StreamCore.xcodeproj/xcshareddata/xcschemes/StreamCore.xcscheme +++ b/StreamCore.xcodeproj/xcshareddata/xcschemes/StreamCore.xcscheme @@ -27,8 +27,13 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES" - shouldAutocreateTestPlan = "YES"> + shouldUseLaunchSchemeArgsEnv = "YES"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/StreamCoreTests/Mocks/EventBatcher_Mock.swift b/Tests/StreamCoreTests/Mocks/EventBatcher_Mock.swift index d692b39..9a0491b 100644 --- a/Tests/StreamCoreTests/Mocks/EventBatcher_Mock.swift +++ b/Tests/StreamCoreTests/Mocks/EventBatcher_Mock.swift @@ -6,12 +6,12 @@ import Foundation @testable import StreamCore final class EventBatcher_Mock: Batcher, @unchecked Sendable { - let handler: @Sendable (_ batch: [Event], _ completion: @escaping @Sendable() -> Void) -> Void + let handler: @Sendable (_ batch: [Event], _ completion: @escaping @Sendable () -> Void) -> Void override init( period: TimeInterval = 0, timerType: StreamCore.Timer.Type = DefaultTimer.self, - handler: @escaping @Sendable (_ batch: [Event], _ completion: @escaping @Sendable() -> Void) -> Void + handler: @escaping @Sendable (_ batch: [Event], _ completion: @escaping @Sendable () -> Void) -> Void ) { self.handler = handler super.init(period: period, timerType: timerType, handler: handler) diff --git a/Tests/StreamCoreTests/StreamCore.xctestplan b/Tests/StreamCoreTests/StreamCore.xctestplan new file mode 100644 index 0000000..32505de --- /dev/null +++ b/Tests/StreamCoreTests/StreamCore.xctestplan @@ -0,0 +1,25 @@ +{ + "configurations" : [ + { + "id" : "3E99132C-0631-473C-9396-C9A968076DFD", + "name" : "Test Scheme Action", + "options" : { + + } + } + ], + "defaultOptions" : { + + }, + "testTargets" : [ + { + "parallelizable" : true, + "target" : { + "containerPath" : "container:StreamCore.xcodeproj", + "identifier" : "845495272DBA3A1300211413", + "name" : "StreamCoreTests" + } + } + ], + "version" : 1 +} diff --git a/Tests/StreamCoreTests/TestUtils/VirtualTime/VirtualTimer.swift b/Tests/StreamCoreTests/TestUtils/VirtualTime/VirtualTimer.swift index b60d584..cd96881 100644 --- a/Tests/StreamCoreTests/TestUtils/VirtualTime/VirtualTimer.swift +++ b/Tests/StreamCoreTests/TestUtils/VirtualTime/VirtualTimer.swift @@ -4,6 +4,7 @@ import Foundation @testable import StreamCore + struct VirtualTimeTimer: StreamCore.Timer { nonisolated(unsafe) static var time: VirtualTime! @@ -13,7 +14,7 @@ struct VirtualTimeTimer: StreamCore.Timer { } static func schedule(timeInterval: TimeInterval, queue: DispatchQueue, onFire: @escaping () -> Void) -> TimerControl { - Self.time.scheduleTimer( + time.scheduleTimer( interval: timeInterval, repeating: false, callback: { _ in onFire() } @@ -25,7 +26,7 @@ struct VirtualTimeTimer: StreamCore.Timer { queue: DispatchQueue, onFire: @escaping () -> Void ) -> RepeatingTimerControl { - Self.time.scheduleTimer( + time.scheduleTimer( interval: timeInterval, repeating: true, callback: { _ in onFire() } diff --git a/Tests/StreamCoreTests/Utils/StringExtensions_Tests.swift b/Tests/StreamCoreTests/Utils/StringExtensions_Tests.swift index 90e398f..d984882 100644 --- a/Tests/StreamCoreTests/Utils/StringExtensions_Tests.swift +++ b/Tests/StreamCoreTests/Utils/StringExtensions_Tests.swift @@ -6,7 +6,6 @@ import XCTest final class StringExtensions_Tests: XCTestCase { - func test_Levenshtein() throws { XCTAssertEqual("".levenshtein(""), "".levenshtein("")) XCTAssertEqual("".levenshtein(""), 0) diff --git a/Tests/StreamCoreUITests/CDN/StreamImageCDN_Tests.swift b/Tests/StreamCoreUITests/CDN/StreamImageCDN_Tests.swift index 4ea7644..f228636 100644 --- a/Tests/StreamCoreUITests/CDN/StreamImageCDN_Tests.swift +++ b/Tests/StreamCoreUITests/CDN/StreamImageCDN_Tests.swift @@ -9,6 +9,7 @@ import XCTest final class StreamImageCDN_Tests: XCTestCase { let baseUrl = "https://www.\(StreamImageCDN.streamCDNURL)" + let displayScale = UITraitCollection.current.displayScale func test_cachingKey_whenHostIsNotStreamCDN() { let streamCDN = StreamImageCDN() @@ -92,8 +93,8 @@ final class StreamImageCDN_Tests: XCTestCase { ) ) - let w: Int = Int(40 * UIScreen.main.scale) - let h: Int = Int(60 * UIScreen.main.scale) + let w: Int = Int(40 * displayScale) + let h: Int = Int(60 * displayScale) AssertEqualURL( processedURLRequest.url!, @@ -114,8 +115,8 @@ final class StreamImageCDN_Tests: XCTestCase { ) ) - let w: Int = Int(40 * UIScreen.main.scale) - let h: Int = Int(60 * UIScreen.main.scale) + let w: Int = Int(40 * displayScale) + let h: Int = Int(60 * displayScale) AssertEqualURL( processedURLRequest.url!, diff --git a/Tests/StreamCoreUITests/StreamCoreUI.xctestplan b/Tests/StreamCoreUITests/StreamCoreUI.xctestplan new file mode 100644 index 0000000..b4ad11e --- /dev/null +++ b/Tests/StreamCoreUITests/StreamCoreUI.xctestplan @@ -0,0 +1,24 @@ +{ + "configurations" : [ + { + "id" : "5A2B6D63-0972-4DF9-A346-C2380775F973", + "name" : "Configuration 1", + "options" : { + + } + } + ], + "defaultOptions" : { + + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:StreamCore.xcodeproj", + "identifier" : "8415DA162E462EEF00FEE25F", + "name" : "StreamCoreUITests" + } + } + ], + "version" : 1 +} diff --git a/Tests/StreamCoreUITests/StreamCoreUITests-Bridging-Header.h b/Tests/StreamCoreUITests/StreamCoreUITests-Bridging-Header.h deleted file mode 100644 index 1b2cb5d..0000000 --- a/Tests/StreamCoreUITests/StreamCoreUITests-Bridging-Header.h +++ /dev/null @@ -1,4 +0,0 @@ -// -// Use this file to import your target's public headers that you would like to expose to Swift. -// - diff --git a/fastlane/Fastfile b/fastlane/Fastfile index b3854f3..ba4e262 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -2,11 +2,135 @@ default_platform :ios opt_out_usage skip_docs +require 'json' +require 'net/http' +import 'Sonarfile' + +xcode_version = ENV['XCODE_VERSION'] || '16.4' +xcode_project = 'StreamCore.xcodeproj' +sdk_names = ['StreamCore', 'StreamCoreUI'] +github_repo = ENV['GITHUB_REPOSITORY'] || 'GetStream/stream-core-swift' +derived_data_path = 'derived_data' +source_packages_path = 'spm_cache' +swift_environment_path = File.absolute_path("../Sources/#{sdk_names.first}/Utils/SystemEnvironment+Version.swift") +is_localhost = !is_ci +@force_check = false + before_all do |lane| if is_ci setup_ci setup_git_config + select_xcode(version: xcode_version) unless [:sonar_upload, :copyright, :merge_main].include?(lane) + end +end + +desc 'Starts a new release' +lane :release do |options| + extra_changes = lambda do |release_version| + # Set the framework version in SystemEnvironment+Version.swift + old_content = File.read(swift_environment_path) + current_version = old_content[/version: String = "([^"]+)"/, 1] + new_content = old_content.gsub(current_version, release_version) + File.open(swift_environment_path, 'w') { |f| f.puts(new_content) } end + + release_ios_sdk( + version: options[:version], + bump_type: options[:type], + sdk_names: sdk_names, + github_repo: github_repo, + extra_changes: extra_changes, + create_pull_request: true + ) +end + +lane :merge_release do |options| + merge_release_to_main(author: options[:author]) + sh('gh workflow run release-publish.yml --ref main') +end + +lane :merge_main do + merge_main_to_develop + update_release_version_to_snapshot(file_path: swift_environment_path) + ensure_git_branch(branch: 'develop') + sh("git add #{swift_environment_path}") + sh("git commit -m 'Update release version to snapshot'") + sh('git push') +end + +desc 'Completes an SDK Release' +lane :publish_release do |options| + release_version = get_sdk_version_from_environment + UI.user_error!("Release #{release_version} has already been published.") if git_tag_exists(tag: release_version, remote: true) + UI.user_error!('Release version cannot be empty') if release_version.to_s.empty? + + ensure_git_branch(branch: 'main') + + publish_ios_sdk( + skip_git_status_check: false, + version: release_version, + github_repo: github_repo + ) + + sh('gh workflow run merge-main-to-develop.yml --ref main') +end + +lane :get_sdk_version_from_environment do + File.read(swift_environment_path).match(/String\s+=\s+"([\d.]+).*"/)[1] +end + +desc 'Runs StreamCore tests' +lane :test do |options| + next unless is_check_required(sources: sources_matrix[:llc], force_check: @force_check) + + scan_options = { + project: xcode_project, + scheme: 'StreamCore', + testplan: 'StreamCore', + clean: is_localhost, + derived_data_path: derived_data_path, + cloned_source_packages_path: source_packages_path, + devices: options[:device], + number_of_retries: 3, + skip_build: options[:skip_build], + build_for_testing: options[:build_for_testing] + } + + scan(scan_options) + + slather unless options[:build_for_testing] +end + +desc 'Runs StreamCoreUI tests' +lane :test_ui do |options| + next unless is_check_required(sources: sources_matrix[:ui], force_check: @force_check) + + scan_options = { + project: xcode_project, + scheme: 'StreamCoreUI', + testplan: 'StreamCoreUI', + clean: is_localhost, + derived_data_path: derived_data_path, + cloned_source_packages_path: source_packages_path, + devices: options[:device], + number_of_retries: 3, + skip_build: options[:skip_build], + build_for_testing: options[:build_for_testing] + } + + scan(scan_options) +end + +desc 'Run fastlane linting' +lane :rubocop do + next unless is_check_required(sources: sources_matrix[:ruby], force_check: @force_check) + + sh('bundle exec rubocop') +end + +desc 'Run PR linting' +lane :lint_pr do + danger(dangerfile: 'Dangerfile') if is_ci end desc 'Run source code formatting/linting' @@ -21,8 +145,25 @@ lane :run_swift_format do |options| end end +lane :install_runtime do |options| + install_ios_runtime(version: options[:ios], custom_script: 'Scripts/install_ios_runtime.sh') +end + lane :sources_matrix do { + llc: ['Sources/StreamCore/', 'Tests/StreamCoreTests'], + ui: ['Sources/StreamCoreUI/', 'Tests/StreamCoreUITests'], + ruby: ['fastlane', 'Gemfile', 'Gemfile.lock'], swiftformat: ['Sources', 'Tests', 'Package.swift'] } end + +lane :copyright do + update_copyright(ignore: [derived_data_path, source_packages_path, 'vendor/']) + next unless is_ci + + pr_create( + title: '[CI] Update Copyright', + head_branch: "ci/update-copyright-#{Time.now.to_i}" + ) +end diff --git a/fastlane/Pluginfile b/fastlane/Pluginfile index d8b6303..60e26e7 100644 --- a/fastlane/Pluginfile +++ b/fastlane/Pluginfile @@ -3,4 +3,4 @@ # Ensure this file is checked in to source control! gem 'fastlane-plugin-versioning' -gem 'fastlane-plugin-stream_actions', '0.3.87' +gem 'fastlane-plugin-stream_actions', '0.3.90' diff --git a/fastlane/Sonarfile b/fastlane/Sonarfile new file mode 100755 index 0000000..b86ddb6 --- /dev/null +++ b/fastlane/Sonarfile @@ -0,0 +1,18 @@ +#!/usr/bin/env ruby + +desc 'Get code coverage report and run complexity analysis for Sonar' +lane :sonar_upload do + next unless is_check_required(sources: sources_matrix[:llc], force_check: @force_check) + + version_number = get_version_number( + xcodeproj: 'StreamCore.xcodeproj', + target: 'StreamCore' + )[/\d+\.\d+\.\d/] + + Dir.chdir('..') do + sh("./fastlane/sonar/bin/sonar-scanner " \ + "-Dsonar.projectVersion=#{version_number} " \ + "-Dproject.settings=sonar-project.properties " \ + "-Dsonar.coverageReportPaths='reports/sonarqube-generic-coverage.xml'") + end +end diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..066443d --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,14 @@ +sonar.projectKey=GetStream_stream-core-swift +sonar.projectName=stream-core-swift +sonar.host.url=https://sonarcloud.io +sonar.organization=getstream +sonar.sourceEncoding=UTF-8 +sonar.language=swift +# sonar.swift.swiftlint.report=reports/swiftlint.txt +sonar.inclusions=Sources/StreamCore/**/*.swift, Sources/StreamCoreUI/**/*.swift +sonar.exclusions=Sources/**/*_Vendor.swift, Sources/**/Generated/**, Sources/**/generated/**, Sources/**/protobuf/**, Sources/**/OpenApi/** + +# Prevent C/C++/Objective-C files from being analyzed +sonar.c.file.suffixes=- +sonar.cpp.file.suffixes=- +sonar.objc.file.suffixes=- From 95391cba2baf043ac73b7eb4c483d83fa8500621 Mon Sep 17 00:00:00 2001 From: Alexey Alter-Pesotskiy Date: Mon, 8 Sep 2025 11:47:16 +0100 Subject: [PATCH 02/11] [CI] Change tests deployment target from 15.6 to 15.5 (#4) --- StreamCore.xcodeproj/project.pbxproj | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/StreamCore.xcodeproj/project.pbxproj b/StreamCore.xcodeproj/project.pbxproj index 04fb545..c2a7f66 100644 --- a/StreamCore.xcodeproj/project.pbxproj +++ b/StreamCore.xcodeproj/project.pbxproj @@ -417,7 +417,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -450,7 +450,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -475,7 +475,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = io.getstream.StreamCoreUITests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -492,7 +492,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = io.getstream.StreamCoreUITests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -621,7 +621,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; @@ -683,7 +683,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; @@ -702,7 +702,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = io.getstream.StreamCoreTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -717,7 +717,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = io.getstream.StreamCoreTests; PRODUCT_NAME = "$(TARGET_NAME)"; From 27ca8b018c5e62850aca2dfb17fcaf449b828bed Mon Sep 17 00:00:00 2001 From: Alexey Alter-Pesotskiy Date: Tue, 9 Sep 2025 15:47:00 +0100 Subject: [PATCH 03/11] [CI] Merge main to develop after release on the same workflow run --- .github/workflows/merge-main-to-develop.yml | 32 --------------------- .github/workflows/release-merge.yml | 2 +- .github/workflows/release-publish.yml | 25 ++++++++++++++++ fastlane/Fastfile | 2 -- 4 files changed, 26 insertions(+), 35 deletions(-) delete mode 100644 .github/workflows/merge-main-to-develop.yml diff --git a/.github/workflows/merge-main-to-develop.yml b/.github/workflows/merge-main-to-develop.yml deleted file mode 100644 index 43b7da2..0000000 --- a/.github/workflows/merge-main-to-develop.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: "Merge main to develop" - -on: - workflow_dispatch: - -permissions: - contents: write - -jobs: - merge: - name: Merge - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4.1.1 - with: - token: ${{ secrets.ADMIN_API_TOKEN }} - fetch-depth: 0 - - - uses: ./.github/actions/ruby-cache - - - run: bundle exec fastlane merge_main - env: - GITHUB_TOKEN: ${{ secrets.ADMIN_API_TOKEN }} - - - uses: 8398a7/action-slack@v3 - if: failure() - with: - status: ${{ job.status }} - text: "⚠️ , the merge of `main` to `develop` failed on CI. Consider using this command locally: `bundle exec fastlane merge_main`" - fields: repo,commit,author,workflow - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/.github/workflows/release-merge.yml b/.github/workflows/release-merge.yml index 250cab8..0176363 100644 --- a/.github/workflows/release-merge.yml +++ b/.github/workflows/release-merge.yml @@ -7,7 +7,7 @@ on: workflow_dispatch: jobs: - merge-comment: + merge-release-to-main: name: Merge release to main runs-on: macos-15 if: github.event_name == 'workflow_dispatch' || (github.event.issue.pull_request && github.event.issue.state == 'open' && github.event.comment.body == '/merge release') diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 5ed3cca..b135cf1 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -23,3 +23,28 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.CI_BOT_GITHUB_TOKEN }} run: bundle exec fastlane publish_release --verbose + + merge-main-to-develop: + name: Merge main to develop + runs-on: ubuntu-latest + needs: release + steps: + - uses: actions/checkout@v4.1.1 + with: + token: ${{ secrets.ADMIN_API_TOKEN }} + fetch-depth: 0 + + - uses: ./.github/actions/ruby-cache + + - run: bundle exec fastlane merge_main + env: + GITHUB_TOKEN: ${{ secrets.ADMIN_API_TOKEN }} + + - uses: 8398a7/action-slack@v3 + if: failure() + with: + status: ${{ job.status }} + text: "⚠️ , the merge of `main` to `develop` failed on CI. Consider using this command locally: `bundle exec fastlane merge_main`" + fields: repo,commit,author,workflow + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/fastlane/Fastfile b/fastlane/Fastfile index ba4e262..ece490a 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -71,8 +71,6 @@ lane :publish_release do |options| version: release_version, github_repo: github_repo ) - - sh('gh workflow run merge-main-to-develop.yml --ref main') end lane :get_sdk_version_from_environment do From c746568f24e71ee47eac84188f10e5892d832c7a Mon Sep 17 00:00:00 2001 From: Alexey Alter-Pesotskiy Date: Fri, 12 Sep 2025 11:06:04 +0100 Subject: [PATCH 04/11] Skip changelog danger rule --- Dangerfile | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/Dangerfile b/Dangerfile index d540119..dbf4167 100644 --- a/Dangerfile +++ b/Dangerfile @@ -1,45 +1,45 @@ pr_body = github.pr_body pr_labels = github.pr_labels -if pr_body.include?("#skip_danger") - message("Skipping Danger due to skip_danger tag") +if pr_body.include?('#skip_danger') + message('Skipping Danger due to skip_danger tag') return end # Make it more obvious that a PR is a work in progress and shouldn't be merged yet has_wip_labels = pr_labels.any? { |label| label =~ /(WIP|Help Wanted)/ } if github.pr_json.draft || has_wip_labels - message("Skipping Danger since the Pull Request is classed as `Draft`/`Work In Progress`") + message('Skipping Danger since the Pull Request is classed as `Draft`/`Work In Progress`') return end # Don't forget to tick the required checkboxes in a PR description -missed_checkboxes = pr_body.each_line.any? { |line| line.include?("[ ]") && line.include?("(required)") } -warn("Please be sure to complete the `Contributor Checklist` in the Pull Request description") if missed_checkboxes +missed_checkboxes = pr_body.each_line.any? { |line| line.include?('[ ]') && line.include?('(required)') } +warn('Please be sure to complete the `Contributor Checklist` in the Pull Request description') if missed_checkboxes # Warn when there is a big PR. -warn("Big PR") if git.lines_of_code > 500 +warn('Big PR') if git.lines_of_code > 500 # Mainly to encourage writing up some reasoning about the PR, rather than just leaving a title. -warn("Please provide a summary in the Pull Request description") if pr_body.length < 3 && git.lines_of_code > 50 +warn('Please provide a summary in the Pull Request description') if pr_body.length < 3 && git.lines_of_code > 50 # Add a CHANGELOG entry for app changes -has_changelog_escape_labels = pr_labels.any? { |label| label =~ /(Meta|Demo App)/ } -has_changelog_escape_tags = pr_body =~ /(#no_changelog|#skip_changelog)/ -has_app_changes = !git.modified_files.grep(/Sources/).empty? -has_changelog_changes = !git.modified_files.include?("CHANGELOG.md") -if !has_changelog_escape_labels && !has_changelog_escape_tags && has_changelog_changes && has_app_changes - message("There seems to be app changes but CHANGELOG wasn't modified." \ - "\nPlease include an entry if the PR includes user-facing changes." \ - "\nYou can find it at [CHANGELOG.md](https://github.com/#{ENV['GITHUB_REPOSITORY']}/blob/main/CHANGELOG.md).") -end +# has_changelog_escape_labels = pr_labels.any? { |label| label =~ /(Meta|Demo App)/ } +# has_changelog_escape_tags = pr_body =~ /(#no_changelog|#skip_changelog)/ +# has_app_changes = !git.modified_files.grep(/Sources/).empty? +# has_changelog_changes = !git.modified_files.include?("CHANGELOG.md") +# if !has_changelog_escape_labels && !has_changelog_escape_tags && has_changelog_changes && has_app_changes +# message("There seems to be app changes but CHANGELOG wasn't modified." \ +# "\nPlease include an entry if the PR includes user-facing changes." \ +# "\nYou can find it at [CHANGELOG.md](https://github.com/#{ENV['GITHUB_REPOSITORY']}/blob/main/CHANGELOG.md).") +# end # Make it clear that a PR is ready for QA and needs to be picked up by someone to test the changes -has_ticked_qa_checkbox = pr_body.include?("[x] This PR should be manually QAed") -has_ready_for_qa_label = pr_labels.any? { |label| label.include?("Ready For QA") } -has_qaed_label = pr_labels.any? { |label| label.include?("QAed") } +has_ticked_qa_checkbox = pr_body.include?('[x] This PR should be manually QAed') +has_ready_for_qa_label = pr_labels.any? { |label| label.include?('Ready For QA') } +has_qaed_label = pr_labels.any? { |label| label.include?('QAed') } if !has_qaed_label && (has_ready_for_qa_label || has_ticked_qa_checkbox) - warn("The changes should be manually QAed before the Pull Request will be merged") + warn('The changes should be manually QAed before the Pull Request will be merged') end # Check all commits have correct format. Disable the length rule, since it's hardcoded to 50 and GitHub has the limit 80 From cd86205ef5513a098cd836f44393da8b771ed143 Mon Sep 17 00:00:00 2001 From: Toomas Vahter Date: Mon, 15 Sep 2025 11:40:11 +0300 Subject: [PATCH 05/11] Add Filter.matches() for local filter matching (#2) --- Gemfile.lock | 1 + Sources/StreamCore/Query/Filter+Local.swift | 229 +++ Sources/StreamCore/Query/Filter.swift | 54 +- Sources/StreamCore/Query/Sort.swift | 35 +- .../OpenAPI/Filter/Filter_Tests.swift | 386 ----- .../StreamCoreTests/Query/Filter_Tests.swift | 1245 +++++++++++++++++ 6 files changed, 1526 insertions(+), 424 deletions(-) create mode 100644 Sources/StreamCore/Query/Filter+Local.swift delete mode 100644 Tests/StreamCoreTests/OpenAPI/Filter/Filter_Tests.swift create mode 100644 Tests/StreamCoreTests/Query/Filter_Tests.swift diff --git a/Gemfile.lock b/Gemfile.lock index 95982e3..fdac3fa 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -341,6 +341,7 @@ GEM PLATFORMS arm64-darwin-23 + arm64-darwin-24 DEPENDENCIES danger diff --git a/Sources/StreamCore/Query/Filter+Local.swift b/Sources/StreamCore/Query/Filter+Local.swift new file mode 100644 index 0000000..c75636f --- /dev/null +++ b/Sources/StreamCore/Query/Filter+Local.swift @@ -0,0 +1,229 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +/// A filter matcher which rrases the type of the value the filter matches against. +/// +/// Allows avoiding generics in individual ``Filter`` instances with the cost of manual type matching using `RawJSON`. +/// ``AnyFilterMatcher`` instances are used by ``FilterFieldRepresentable`` and allow matching values based on the field and operator. +public struct AnyFilterMatcher: Sendable where Model: Sendable { + private let match: @Sendable (Model, any FilterValue, FilterOperator) -> Bool + + public init(localValue: @escaping @Sendable (Model) -> Value?) where Value: FilterValue { + match = FilterMatcher(localValue: localValue).match + } + + func match(_ model: Model, to value: any FilterValue, filterOperator: FilterOperator) -> Bool { + match(model, value, filterOperator) + } +} + +extension Filter { + /// Evaluates whether a model matches the current filter criteria. + /// + /// This method performs local filtering by evaluating the filter against a provided model. + /// It handles both simple filters (using field matchers) and compound filters (using logical operators). + /// + /// - Parameter model: The model to evaluate against the filter criteria. + /// Must conform to the same type as specified in the filter's field. + /// + /// - Returns: `true` if the model matches the filter criteria, `false` otherwise. + /// + /// ## Example Usage + /// ```swift + /// let filter = Filter.equal("name", "John") + /// let user = User(name: "John", age: 30) + /// let matches = filter.matches(user) // true + /// + /// let compoundFilter = Filter.and([ + /// Filter.equal("name", "John"), + /// Filter.greater("age", 25) + /// ]) + /// let matches = compoundFilter.matches(user) // true + /// ``` + public func matches(_ model: Model) -> Bool where Model == FilterField.Model { + switch filterOperator { + case .and: + guard let subfilters = value as? [Self] else { return false } + return subfilters.allSatisfy { $0.matches(model) } + case .or: + guard let subfilters = value as? [Self] else { return false } + return subfilters.contains(where: { $0.matches(model) }) + default: + return field.matcher.match(model, to: value, filterOperator: filterOperator) + } + } +} + +private struct FilterMatcher: Sendable where Model: Sendable, Value: FilterValue { + let localValue: @Sendable (Model) -> Value + + init(localValue: @escaping @Sendable (Model) -> Value) { + self.localValue = localValue + } + + func match(_ model: Model, to value: any FilterValue, filterOperator: FilterOperator) -> Bool { + let localRawJSONValue = localValue(model).rawJSON + let filterRawJSONValue = value.rawJSON + + switch filterOperator { + case .exists: + return Self.exists(localRawJSONValue, filterRawJSONValue) + case .equal: + return Self.isEqual(localRawJSONValue, filterRawJSONValue) + case .greater: + return Self.isGreater(localRawJSONValue, filterRawJSONValue) + case .greaterOrEqual: + return Self.isGreaterOrEqual(localRawJSONValue, filterRawJSONValue) + case .less: + return Self.isLess(localRawJSONValue, filterRawJSONValue) + case .lessOrEqual: + return Self.isLessOrEqual(localRawJSONValue, filterRawJSONValue) + case .autocomplete: + return Self.autocomplete(localRawJSONValue, filterRawJSONValue) + case .query: + return Self.query(localRawJSONValue, filterRawJSONValue) + case .contains: + return Self.contains(localRawJSONValue, filterRawJSONValue) + case .in: + return Self.isIn(localRawJSONValue, filterRawJSONValue) + case .pathExists: + return Self.pathExists(localRawJSONValue, filterRawJSONValue) + case .and, .or: + log.debug("Should never try to match compound operators") + return false + } + } + + static func exists(_ localRawJSONValue: RawJSON, _ filterRawJSONValue: RawJSON) -> Bool { + switch filterRawJSONValue { + case .bool(let exists): exists ? !localRawJSONValue.isNil : localRawJSONValue.isNil + default: false + } + } + + static func isEqual(_ localRawJSONValue: RawJSON, _ filterRawJSONValue: RawJSON) -> Bool { + localRawJSONValue == filterRawJSONValue + } + + static func isGreater(_ localRawJSONValue: RawJSON, _ filterRawJSONValue: RawJSON) -> Bool { + switch (localRawJSONValue, filterRawJSONValue) { + case (.number(let lhsValue), .number(let rhsValue)): lhsValue > rhsValue + case (.string(let lhsValue), .string(let rhsValue)): rhsValue.lexicographicallyPrecedes(lhsValue) + default: false + } + } + + static func isGreaterOrEqual(_ localRawJSONValue: RawJSON, _ filterRawJSONValue: RawJSON) -> Bool { + switch (localRawJSONValue, filterRawJSONValue) { + case (.number(let lhsValue), .number(let rhsValue)): lhsValue >= rhsValue + case (.string(let lhsValue), .string(let rhsValue)): rhsValue.lexicographicallyPrecedes(lhsValue) || rhsValue == lhsValue + default: false + } + } + + static func isLess(_ localRawJSONValue: RawJSON, _ filterRawJSONValue: RawJSON) -> Bool { + switch (localRawJSONValue, filterRawJSONValue) { + case (.number(let lhsValue), .number(let rhsValue)): lhsValue < rhsValue + case (.string(let lhsValue), .string(let rhsValue)): lhsValue.lexicographicallyPrecedes(rhsValue) + default: false + } + } + + static func isLessOrEqual(_ localRawJSONValue: RawJSON, _ filterRawJSONValue: RawJSON) -> Bool { + switch (localRawJSONValue, filterRawJSONValue) { + case (.number(let lhsValue), .number(let rhsValue)): lhsValue <= rhsValue + case (.string(let lhsValue), .string(let rhsValue)): lhsValue.lexicographicallyPrecedes(rhsValue) || lhsValue == rhsValue + default: false + } + } + + static func autocomplete(_ localRawJSONValue: RawJSON, _ filterRawJSONValue: RawJSON) -> Bool { + switch (localRawJSONValue, filterRawJSONValue) { + case (.string(let localStringValue), .string(let filterStringValue)): + Self.postgreSQLFullTextSearch(anchored: true, text: localStringValue, query: filterStringValue) + default: + false + } + } + + static func query(_ localRawJSONValue: RawJSON, _ filterRawJSONValue: RawJSON) -> Bool { + switch (localRawJSONValue, filterRawJSONValue) { + case (.string(let localStringValue), .string(let filterStringValue)): + Self.postgreSQLFullTextSearch(anchored: false, text: localStringValue, query: filterStringValue) + default: + false + } + } + + static func contains(_ localRawJSONValue: RawJSON, _ filterRawJSONValue: RawJSON) -> Bool { + switch (localRawJSONValue, filterRawJSONValue) { + case (.array(let localArrayValue), .array(let filterArrayValue)): + return filterArrayValue.allSatisfy { localArrayValue.contains($0) } + case (.array(let localArrayValue), _): // string, number etc + return localArrayValue.contains(filterRawJSONValue) + case (.dictionary(let localDictionaryValue), .dictionary(let filterDictionaryValue)): + if filterDictionaryValue.isEmpty { + return localDictionaryValue.isEmpty + } + // Partial matching + return filterDictionaryValue.allSatisfy { (filterKey, filterValue) in + guard let localValue = localDictionaryValue[filterKey] else { return false } + if contains(localValue, filterValue) { // array & dictionary + return true + } + // Match single values: strings, numbers + return isEqual(localValue, filterValue) + } + default: + return false + } + } + + static func isIn(_ localRawJSONValue: RawJSON, _ filterRawJSONValue: RawJSON) -> Bool { + switch filterRawJSONValue { + case .array(let rawJSONArrayValue): + rawJSONArrayValue.contains(localRawJSONValue) + default: + false + } + } + + static func pathExists(_ localRawJSONValue: RawJSON, _ filterRawJSONValue: RawJSON) -> Bool { + switch (localRawJSONValue, filterRawJSONValue) { + case (.dictionary(let dictionaryValue), .string(let path)): + let components = path.components(separatedBy: ".") + guard !components.isEmpty else { return false } + + var next: [String: RawJSON]? = dictionaryValue + for component in components { + if let nextValue = next?[component] { + next = nextValue.dictionaryValue + } else { + return false + } + } + return true + default: + return false + } + } + + // MARK: - + + /// PostgreSQL-style full-text search for tokenized text and word boundary matching + /// + /// - Important: This is a simplified implementation. + private static func postgreSQLFullTextSearch(anchored: Bool, text: String, query: String) -> Bool { + guard !query.isEmpty else { return false } + let options: String.CompareOptions = anchored ? [.anchored, .caseInsensitive] : [.caseInsensitive] + // Entire text starts with the query (single or phrase) + if text.range(of: query, options: options) != nil { + return true + } + let words = text.components(separatedBy: CharacterSet.whitespacesAndNewlines.union(.punctuationCharacters)) + return words.contains(where: { $0.range(of: query, options: options) != nil }) + } +} diff --git a/Sources/StreamCore/Query/Filter.swift b/Sources/StreamCore/Query/Filter.swift index 33f4cda..bb4bd53 100644 --- a/Sources/StreamCore/Query/Filter.swift +++ b/Sources/StreamCore/Query/Filter.swift @@ -14,7 +14,7 @@ public protocol Filter: FilterValue, Sendable { /// The associated type representing the field that this filter operates on. associatedtype FilterField: FilterFieldRepresentable - /// The field to filter on (e.g., "id", "fid", "user_id"). + /// The field to filter on (e.g., "id", "feed", "user_id"). var field: FilterField { get } /// The value to compare against the field. @@ -42,21 +42,29 @@ public protocol FilterValue: Sendable {} /// This protocol allows for type-safe field names while maintaining the ability to convert to string values /// for API communication. public protocol FilterFieldRepresentable: Sendable { + /// The model type that this filter field operates on. + associatedtype Model: Sendable + + /// A matcher that can be used for local matching operations. + var matcher: AnyFilterMatcher { get } + /// The string representation of the field. - var value: String { get } + var rawValue: String { get } - /// Creates a field representation from a string value. + /// Creates a new filter field with the specified remote identifier and local value extractor. /// - /// - Parameter value: The string value representing the field. - init(value: String) + /// - Parameters: + /// - rawValue: The string identifier used for remote API requests + /// - localValue: A closure that extracts the comparable value from a model instance + init(_ rawValue: String, localValue: @escaping @Sendable (Model) -> Value?) where Value: FilterValue } extension FilterFieldRepresentable { - /// Logical AND operator for combining multiple filters. - static var and: Self { Self(value: "$and") } - - /// Logical OR operator for combining multiple filters. - static var or: Self { Self(value: "$or") } + /// Placeholder value for compound filters. + /// + /// $and and $or ignore the field itself because the operation does not compare any actual data like other operators are + /// This placeholder allows the public API to not have optional field parameter. Note how field is ignored in ``Filter.matches(_:)`` for compound operators. + static var compoundOperatorPlaceholderField: Self { Self("", localValue: { _ in 0 }) } } // MARK: - Filter Building @@ -136,12 +144,22 @@ extension Filter { /// /// - Parameters: /// - field: The field to search in. - /// - value: The string to search for. - /// - Returns: A filter that matches when the field contains the specified string. - public static func contains(_ field: FilterField, _ value: String) -> Self { + /// - value: The value to search for. + /// - Returns: A filter that matches when the field contains the specified value. + public static func contains(_ field: FilterField, _ value: Value) -> Self where Value: FilterValue { Self(filterOperator: .contains, field: field, value: value) } + /// Creates a filter that checks if a field contains specific key-value pairs. + /// + /// - Parameters: + /// - field: The field to search in. + /// - value: An array of values to search for. + /// - Returns: A filter that matches when the field contains the specified key-value pairs. + public static func contains(_ field: FilterField, _ values: [Value]) -> Self where Value: FilterValue { + Self(filterOperator: .contains, field: field, value: values) + } + /// Creates a filter that checks if a field contains specific key-value pairs. /// /// - Parameters: @@ -187,7 +205,7 @@ extension Filter { /// - Parameter filters: An array of filters to combine. /// - Returns: A filter that matches when all the specified filters match. public static func and(_ filters: [F]) -> F where F: Filter, F.FilterField == FilterField { - F(filterOperator: .and, field: .and, value: filters) + F(filterOperator: .and, field: .compoundOperatorPlaceholderField, value: filters) } /// Creates a filter that combines multiple filters with a logical OR operation. @@ -195,7 +213,7 @@ extension Filter { /// - Parameter filters: An array of filters to combine. /// - Returns: A filter that matches when any of the specified filters match. public static func or(_ filters: [F]) -> F where F: Filter, F.FilterField == FilterField { - F(filterOperator: .or, field: .and, value: filters) + F(filterOperator: .or, field: .compoundOperatorPlaceholderField, value: filters) } } @@ -232,6 +250,8 @@ extension Array: FilterValue where Element: FilterValue {} /// This allows dictionaries to be used in filters for complex object matching. extension Dictionary: FilterValue where Key == String, Value == RawJSON {} +extension Optional: FilterValue where Wrapped: FilterValue {} + // MARK: - Filter to RawJSON Conversion extension Filter { @@ -253,7 +273,7 @@ extension Filter { } else { // Normal filters are encoded in the following form: // { field: { $: } } - return [field.value: .dictionary([filterOperator.rawValue: value.rawJSON])] + return [field.rawValue: .dictionary([filterOperator.rawValue: value.rawJSON])] } } } @@ -282,7 +302,7 @@ extension FilterValue { case let dictionaryValue as [String: RawJSON]: .dictionary(dictionaryValue) default: - fatalError("Unimplemented type: \(self)") + .nil } } } diff --git a/Sources/StreamCore/Query/Sort.swift b/Sources/StreamCore/Query/Sort.swift index eecc17d..4c1200c 100644 --- a/Sources/StreamCore/Query/Sort.swift +++ b/Sources/StreamCore/Query/Sort.swift @@ -19,14 +19,14 @@ public protocol SortField: Sendable { var comparator: AnySortComparator { get } /// The string identifier used when sending sort parameters to the remote API. - var remote: String { get } + var rawValue: String { get } /// Creates a new sort field with the specified remote identifier and local value extractor. /// /// - Parameters: - /// - remote: The string identifier used for remote API requests + /// - rawValue: The string identifier used for remote API requests /// - localValue: A closure that extracts the comparable value from a model instance - init(_ remote: String, localValue: @escaping @Sendable (Model) -> Value) where Value: Comparable + init(_ rawValue: String, localValue: @escaping @Sendable (Model) -> Value) where Value: Comparable } /// A sort configuration that combines a sort field with a direction. @@ -86,7 +86,7 @@ public enum SortDirection: Int, CustomStringConvertible, Sendable { extension Sort: CustomStringConvertible { /// A string representation of the sort configuration in the format "field:direction". - public var description: String { "\(field.remote):\(direction)" } + public var description: String { "\(field.rawValue):\(direction)" } } // MARK: - Local Sorting Support @@ -98,15 +98,15 @@ extension Sort: CustomStringConvertible { /// and direction handling internally. /// /// - Note: Both `Model` and `Value` must conform to `Sendable` for thread safety. -public struct SortComparator: Sendable where Model: Sendable, Value: Comparable { +struct SortComparator: Sendable where Model: Sendable, Value: Comparable { /// A closure that extracts a comparable value from a model instance. - let value: @Sendable (Model) -> Value + let localValue: @Sendable (Model) -> Value /// Creates a new comparator with the specified value extraction closure. /// - /// - Parameter value: A closure that extracts a comparable value from a model instance - public init(_ value: @escaping @Sendable (Model) -> Value) { - self.value = value + /// - Parameter localValue: A closure that extracts a comparable value from a model instance + init(localValue: @escaping @Sendable (Model) -> Value) { + self.localValue = localValue } /// Compares two model instances using the extracted values and sort direction. @@ -116,20 +116,13 @@ public struct SortComparator: Sendable where Model: Sendable, Valu /// - b: The second model instance to compare /// - direction: The direction of the sort /// - Returns: A comparison result indicating the relative ordering - public func compare(_ a: Model, _ b: Model, direction: SortDirection) -> ComparisonResult { - let valueA = value(a) - let valueB = value(b) + func compare(_ a: Model, _ b: Model, direction: SortDirection) -> ComparisonResult { + let valueA = localValue(a) + let valueB = localValue(b) if valueA < valueB { return direction == .forward ? .orderedAscending : .orderedDescending } if valueA > valueB { return direction == .forward ? .orderedDescending : .orderedAscending } return .orderedSame } - - /// Converts this comparator to a type-erased version. - /// - /// - Returns: An `AnySortComparator` that wraps this comparator - public func toAny() -> AnySortComparator { - AnySortComparator(self) - } } /// A type-erased wrapper for sort comparators that can work with any model type. @@ -149,8 +142,8 @@ public struct AnySortComparator: Sendable where Model: Sendable { /// Creates a type-erased comparator from a specific comparator instance. /// /// - Parameter sort: The specific comparator to wrap - init(_ sort: SortComparator) { - compare = sort.compare + public init(localValue: @escaping @Sendable (Model) -> Value) { + compare = SortComparator(localValue: localValue).compare } /// Compares two model instances using the wrapped comparator. diff --git a/Tests/StreamCoreTests/OpenAPI/Filter/Filter_Tests.swift b/Tests/StreamCoreTests/OpenAPI/Filter/Filter_Tests.swift deleted file mode 100644 index cf9af7c..0000000 --- a/Tests/StreamCoreTests/OpenAPI/Filter/Filter_Tests.swift +++ /dev/null @@ -1,386 +0,0 @@ -// -// Copyright © 2025 Stream.io Inc. All rights reserved. -// - -import Foundation -@testable import StreamCore -import Testing - -struct Filter_Tests { - // MARK: - Test Filter Field - - struct TestFilterField: FilterFieldRepresentable { - public let value: String - - public init(value: String) { - self.value = value - } - - public static let name = Self(value: "name") - public static let age = Self(value: "age") - public static let email = Self(value: "email") - public static let tags = Self(value: "tags") - public static let createdAt = Self(value: "created_at") - public static let isActive = Self(value: "is_active") - } - - struct TestFilter: Filter { - typealias FilterField = TestFilterField - - public init(filterOperator: FilterOperator, field: TestFilterField, value: any FilterValue) { - self.filterOperator = filterOperator - self.field = field - self.value = value - } - - public let field: TestFilterField - public let value: any FilterValue - public let filterOperator: FilterOperator - } - - // MARK: - Basic Filter Tests - - @Test("Equal filter with string value") - func equalFilterWithString() { - let filter = TestFilter.equal(.name, "John") - let json = filter.toRawJSON() - - let expected: [String: RawJSON] = [ - "name": .dictionary(["$eq": .string("John")]) - ] - - #expect(json == expected) - } - - @Test("Equal filter with integer value") - func equalFilterWithInteger() { - let filter = TestFilter.equal(.age, 25) - let json = filter.toRawJSON() - - let expected: [String: RawJSON] = [ - "age": .dictionary(["$eq": .number(25.0)]) - ] - - #expect(json == expected) - } - - @Test("Equal filter with boolean value") - func equalFilterWithBoolean() { - let filter = TestFilter.equal(.isActive, true) - let json = filter.toRawJSON() - - let expected: [String: RawJSON] = [ - "is_active": .dictionary(["$eq": .bool(true)]) - ] - - #expect(json == expected) - } - - @Test("Equal filter with date value") - func equalFilterWithDate() { - let date = Date(timeIntervalSince1970: 1_640_995_200) // 2022-01-01 00:00:00 UTC - let filter = TestFilter.equal(.createdAt, date) - let json = filter.toRawJSON() - - let expected: [String: RawJSON] = [ - "created_at": .dictionary(["$eq": .string("2022-01-01T00:00:00.000Z")]) - ] - - #expect(json == expected) - } - - // MARK: - Comparison Filter Tests - - @Test("Greater than filter") - func greaterThanFilter() { - let filter = TestFilter.greater(.age, 18) - let json = filter.toRawJSON() - - let expected: [String: RawJSON] = [ - "age": .dictionary(["$gt": .number(18.0)]) - ] - - #expect(json == expected) - } - - @Test("Greater than or equal filter") - func greaterThanOrEqualFilter() { - let filter = TestFilter.greaterOrEqual(.age, 21) - let json = filter.toRawJSON() - - let expected: [String: RawJSON] = [ - "age": .dictionary(["$gte": .number(21.0)]) - ] - - #expect(json == expected) - } - - @Test("Less than filter") - func lessThanFilter() { - let filter = TestFilter.less(.age, 65) - let json = filter.toRawJSON() - - let expected: [String: RawJSON] = [ - "age": .dictionary(["$lt": .number(65.0)]) - ] - - #expect(json == expected) - } - - @Test("Less than or equal filter") - func lessThanOrEqualFilter() { - let filter = TestFilter.lessOrEqual(.age, 30) - let json = filter.toRawJSON() - - let expected: [String: RawJSON] = [ - "age": .dictionary(["$lte": .number(30.0)]) - ] - - #expect(json == expected) - } - - // MARK: - Array and Collection Filter Tests - - @Test("In filter with array of strings") - func inFilterWithStringArray() { - let filter = TestFilter.in(.name, ["John", "Jane", "Bob"]) - let json = filter.toRawJSON() - - let expected: [String: RawJSON] = [ - "name": .dictionary(["$in": .array([.string("John"), .string("Jane"), .string("Bob")])]) - ] - - #expect(json == expected) - } - - @Test("In filter with array of integers") - func inFilterWithIntegerArray() { - let filter = TestFilter.in(.age, [18, 21, 25, 30]) - let json = filter.toRawJSON() - - let expected: [String: RawJSON] = [ - "age": .dictionary(["$in": .array([.number(18.0), .number(21.0), .number(25.0), .number(30.0)])]) - ] - - #expect(json == expected) - } - - @Test("Contains filter") - func containsFilter() { - let filter = TestFilter.contains(.tags, "swift") - let json = filter.toRawJSON() - - let expected: [String: RawJSON] = [ - "tags": .dictionary(["$contains": .string("swift")]) - ] - - #expect(json == expected) - } - - // MARK: - Text Search Filter Tests - - @Test("Query filter") - func queryFilter() { - let filter = TestFilter.query(.name, "john") - let json = filter.toRawJSON() - - let expected: [String: RawJSON] = [ - "name": .dictionary(["$q": .string("john")]) - ] - - #expect(json == expected) - } - - @Test("Autocomplete filter") - func autocompleteFilter() { - let filter = TestFilter.autocomplete(.name, "jo") - let json = filter.toRawJSON() - - let expected: [String: RawJSON] = [ - "name": .dictionary(["$autocomplete": .string("jo")]) - ] - - #expect(json == expected) - } - - // MARK: - Existence Filter Tests - - @Test("Exists filter") - func existsFilter() { - let filter = TestFilter.exists(.email, true) - let json = filter.toRawJSON() - - let expected: [String: RawJSON] = [ - "email": .dictionary(["$exists": .bool(true)]) - ] - - #expect(json == expected) - } - - @Test("Path exists filter") - func pathExistsFilter() { - let filter = TestFilter.pathExists(.tags, "custom.field") - let json = filter.toRawJSON() - - let expected: [String: RawJSON] = [ - "tags": .dictionary(["$path_exists": .string("custom.field")]) - ] - - #expect(json == expected) - } - - // MARK: - Complex Filter Tests - - @Test("And filter with multiple conditions") - func testAndFilter() { - let ageFilter = TestFilter.greater(.age, 18) - let nameFilter = TestFilter.equal(.name, "John") - let activeFilter = TestFilter.equal(.isActive, true) - - let andFilter = TestFilter.and([ageFilter, nameFilter, activeFilter]) - let json = andFilter.toRawJSON() - - let expected: [String: RawJSON] = [ - "$and": .array([ - .dictionary(["age": .dictionary(["$gt": .number(18.0)])]), - .dictionary(["name": .dictionary(["$eq": .string("John")])]), - .dictionary(["is_active": .dictionary(["$eq": .bool(true)])]) - ]) - ] - - #expect(json == expected) - } - - @Test("Or filter with multiple conditions") - func testOrFilter() { - let nameFilter1 = TestFilter.equal(.name, "John") - let nameFilter2 = TestFilter.equal(.name, "Jane") - - let orFilter = TestFilter.or([nameFilter1, nameFilter2]) - let json = orFilter.toRawJSON() - - let expected: [String: RawJSON] = [ - "$or": .array([ - .dictionary(["name": .dictionary(["$eq": .string("John")])]), - .dictionary(["name": .dictionary(["$eq": .string("Jane")])]) - ]) - ] - - #expect(json == expected) - } - - @Test("Nested and/or filters") - func nestedAndOrFilters() { - let ageFilter = TestFilter.greater(.age, 18) - let nameFilter1 = TestFilter.equal(.name, "John") - let nameFilter2 = TestFilter.equal(.name, "Jane") - - let orFilter = TestFilter.or([nameFilter1, nameFilter2]) - let andFilter = TestFilter.and([ageFilter, orFilter]) - - let json = andFilter.toRawJSON() - - let expected: [String: RawJSON] = [ - "$and": .array([ - .dictionary(["age": .dictionary(["$gt": .number(18.0)])]), - .dictionary([ - "$or": .array([ - .dictionary(["name": .dictionary(["$eq": .string("John")])]), - .dictionary(["name": .dictionary(["$eq": .string("Jane")])]) - ]) - ]) - ]) - ] - - #expect(json == expected) - } - - // MARK: - URL Filter Tests - - @Test("URL filter value") - func uRLFilterValue() { - let url = URL(string: "https://example.com")! - let filter = TestFilter.equal(.email, url) - let json = filter.toRawJSON() - - let expected: [String: RawJSON] = [ - "email": .dictionary(["$eq": .string("https://example.com")]) - ] - - #expect(json == expected) - } - - // MARK: - Dictionary Filter Tests - - @Test("Dictionary filter value") - func dictionaryFilterValue() { - let customData: [String: RawJSON] = [ - "key1": .string("value1"), - "key2": .number(42.0) - ] - let filter = TestFilter.equal(.tags, customData) - let json = filter.toRawJSON() - - let expected: [String: RawJSON] = [ - "tags": .dictionary(["$eq": .dictionary(customData)]) - ] - - #expect(json == expected) - } - - // MARK: - Array Filter Tests - - @Test("Array filter value") - func arrayFilterValue() { - let arrayValue = ["item1", "item2"] - let filter = TestFilter.equal(.tags, arrayValue) - let json = filter.toRawJSON() - - let expected: [String: RawJSON] = [ - "tags": .dictionary(["$eq": .array([.string("item1"), .string("item2")])]) - ] - - #expect(json == expected) - } - - // MARK: - Edge Cases - - @Test("Empty array in filter") - func emptyArrayInFilter() { - let arrayValue: [String] = [] - let filter = TestFilter.in(.tags, arrayValue) - let json = filter.toRawJSON() - - let expected: [String: RawJSON] = [ - "tags": .dictionary(["$in": .array([])]) - ] - - #expect(json == expected) - } - - @Test("Empty and filter") - func emptyAndFilter() { - let subFilters: [TestFilter] = [] - let filter = TestFilter.and(subFilters) - let json = filter.toRawJSON() - - let expected: [String: RawJSON] = [ - "$and": .array([]) - ] - - #expect(json == expected) - } - - @Test("Empty or filter") - func emptyOrFilter() { - let subFilters: [TestFilter] = [] - let filter = TestFilter.or(subFilters) - let json = filter.toRawJSON() - - let expected: [String: RawJSON] = [ - "$or": .array([]) - ] - - #expect(json == expected) - } -} diff --git a/Tests/StreamCoreTests/Query/Filter_Tests.swift b/Tests/StreamCoreTests/Query/Filter_Tests.swift new file mode 100644 index 0000000..b88f54a --- /dev/null +++ b/Tests/StreamCoreTests/Query/Filter_Tests.swift @@ -0,0 +1,1245 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation +@testable import StreamCore +import Testing + +struct Filter_Tests { + // MARK: - Test Filter Field + + struct TestFilterField: FilterFieldRepresentable { + typealias Model = TestUser + let matcher: AnyFilterMatcher + let rawValue: String + + init(_ rawValue: String, localValue: @escaping @Sendable (TestUser) -> Value?) where Value: FilterValue { + self.rawValue = rawValue + matcher = AnyFilterMatcher(localValue: localValue) + } + + static let name = Self("name", localValue: \.name) + static let age = Self("age", localValue: \.age) + static let height = Self("height", localValue: \.height) + static let email = Self("email", localValue: \.email) + static let homepage = Self("homepage", localValue: \.homepage) + static let tags = Self("tags", localValue: \.tags) + static let createdAt = Self("created_at", localValue: \.createdAt) + static let isActive = Self("is_active", localValue: \.isActive) + static let searchData = Self("search_data", localValue: \.searchData) + } + + struct TestFilter: Filter { + typealias FilterField = TestFilterField + + init(filterOperator: FilterOperator, field: TestFilterField, value: any FilterValue) { + self.filterOperator = filterOperator + self.field = field + self.value = value + } + + let field: TestFilterField + let value: any FilterValue + let filterOperator: FilterOperator + } + + // MARK: - Filtered Model + + struct TestUser { + var name: String = "John" + var age: Int = 20 + var height: Double = 180.1 + var email: String = "john@getstream.io" + var homepage: URL? = URL(string: "https://getstream.io") + var tags: [String] = ["orange", "yellow"] + var createdAt: Date = Date(timeIntervalSinceNow: 1_756_728_556) + var isActive: Bool = true + var searchData: [String: RawJSON] = [ + "address": .dictionary([ + "country": .string("NL"), + "city": .string("Amsterdam"), + "street": .string("Kleine-Gartmanplantsoen 21-6") + ]) + ] + } + + // MARK: - Basic Filter Tests + + @Test("Equal filter with string value") + func equalFilterWithString() { + let filter = TestFilter.equal(.name, "John") + let json = filter.toRawJSON() + + let expected: [String: RawJSON] = [ + "name": .dictionary(["$eq": .string("John")]) + ] + + #expect(json == expected) + } + + @Test("Equal filter with integer value") + func equalFilterWithInteger() { + let filter = TestFilter.equal(.age, 25) + let json = filter.toRawJSON() + + let expected: [String: RawJSON] = [ + "age": .dictionary(["$eq": .number(25.0)]) + ] + + #expect(json == expected) + } + + @Test("Equal filter with boolean value") + func equalFilterWithBoolean() { + let filter = TestFilter.equal(.isActive, true) + let json = filter.toRawJSON() + + let expected: [String: RawJSON] = [ + "is_active": .dictionary(["$eq": .bool(true)]) + ] + + #expect(json == expected) + } + + @Test("Equal filter with date value") + func equalFilterWithDate() { + let date = Date(timeIntervalSince1970: 1_640_995_200) // 2022-01-01 00:00:00 UTC + let filter = TestFilter.equal(.createdAt, date) + let json = filter.toRawJSON() + + let expected: [String: RawJSON] = [ + "created_at": .dictionary(["$eq": .string("2022-01-01T00:00:00.000Z")]) + ] + + #expect(json == expected) + } + + // MARK: - Comparison Filter Tests + + @Test("Greater than filter") + func greaterThanFilter() { + let filter = TestFilter.greater(.age, 18) + let json = filter.toRawJSON() + + let expected: [String: RawJSON] = [ + "age": .dictionary(["$gt": .number(18.0)]) + ] + + #expect(json == expected) + } + + @Test("Greater than or equal filter") + func greaterThanOrEqualFilter() { + let filter = TestFilter.greaterOrEqual(.age, 21) + let json = filter.toRawJSON() + + let expected: [String: RawJSON] = [ + "age": .dictionary(["$gte": .number(21.0)]) + ] + + #expect(json == expected) + } + + @Test("Less than filter") + func lessThanFilter() { + let filter = TestFilter.less(.age, 65) + let json = filter.toRawJSON() + + let expected: [String: RawJSON] = [ + "age": .dictionary(["$lt": .number(65.0)]) + ] + + #expect(json == expected) + } + + @Test("Less than or equal filter") + func lessThanOrEqualFilter() { + let filter = TestFilter.lessOrEqual(.age, 30) + let json = filter.toRawJSON() + + let expected: [String: RawJSON] = [ + "age": .dictionary(["$lte": .number(30.0)]) + ] + + #expect(json == expected) + } + + // MARK: - Array and Collection Filter Tests + + @Test("In filter with array of strings") + func inFilterWithStringArray() { + let filter = TestFilter.in(.name, ["John", "Jane", "Bob"]) + let json = filter.toRawJSON() + + let expected: [String: RawJSON] = [ + "name": .dictionary(["$in": .array([.string("John"), .string("Jane"), .string("Bob")])]) + ] + + #expect(json == expected) + } + + @Test("In filter with array of integers") + func inFilterWithIntegerArray() { + let filter = TestFilter.in(.age, [18, 21, 25, 30]) + let json = filter.toRawJSON() + + let expected: [String: RawJSON] = [ + "age": .dictionary(["$in": .array([.number(18.0), .number(21.0), .number(25.0), .number(30.0)])]) + ] + + #expect(json == expected) + } + + @Test("Contains filter") + func containsFilter() { + let filter = TestFilter.contains(.tags, "swift") + let json = filter.toRawJSON() + + let expected: [String: RawJSON] = [ + "tags": .dictionary(["$contains": .string("swift")]) + ] + + #expect(json == expected) + } + + // MARK: - Text Search Filter Tests + + @Test("Query filter") + func queryFilter() { + let filter = TestFilter.query(.name, "john") + let json = filter.toRawJSON() + + let expected: [String: RawJSON] = [ + "name": .dictionary(["$q": .string("john")]) + ] + + #expect(json == expected) + } + + @Test("Autocomplete filter") + func autocompleteFilter() { + let filter = TestFilter.autocomplete(.name, "jo") + let json = filter.toRawJSON() + + let expected: [String: RawJSON] = [ + "name": .dictionary(["$autocomplete": .string("jo")]) + ] + + #expect(json == expected) + } + + // MARK: - Existence Filter Tests + + @Test("Exists filter") + func existsFilter() { + let filter = TestFilter.exists(.email, true) + let json = filter.toRawJSON() + + let expected: [String: RawJSON] = [ + "email": .dictionary(["$exists": .bool(true)]) + ] + + #expect(json == expected) + } + + @Test("Path exists filter") + func pathExistsFilter() { + let filter = TestFilter.pathExists(.tags, "custom.field") + let json = filter.toRawJSON() + + let expected: [String: RawJSON] = [ + "tags": .dictionary(["$path_exists": .string("custom.field")]) + ] + + #expect(json == expected) + } + + // MARK: - Complex Filter Tests + + @Test("And filter with multiple conditions") + func testAndFilter() { + let ageFilter = TestFilter.greater(.age, 18) + let nameFilter = TestFilter.equal(.name, "John") + let activeFilter = TestFilter.equal(.isActive, true) + + let andFilter = TestFilter.and([ageFilter, nameFilter, activeFilter]) + let json = andFilter.toRawJSON() + + let expected: [String: RawJSON] = [ + "$and": .array([ + .dictionary(["age": .dictionary(["$gt": .number(18.0)])]), + .dictionary(["name": .dictionary(["$eq": .string("John")])]), + .dictionary(["is_active": .dictionary(["$eq": .bool(true)])]) + ]) + ] + + #expect(json == expected) + } + + @Test("Or filter with multiple conditions") + func testOrFilter() { + let nameFilter1 = TestFilter.equal(.name, "John") + let nameFilter2 = TestFilter.equal(.name, "Jane") + + let orFilter = TestFilter.or([nameFilter1, nameFilter2]) + let json = orFilter.toRawJSON() + + let expected: [String: RawJSON] = [ + "$or": .array([ + .dictionary(["name": .dictionary(["$eq": .string("John")])]), + .dictionary(["name": .dictionary(["$eq": .string("Jane")])]) + ]) + ] + + #expect(json == expected) + } + + @Test("Nested and/or filters") + func nestedAndOrFilters() { + let ageFilter = TestFilter.greater(.age, 18) + let nameFilter1 = TestFilter.equal(.name, "John") + let nameFilter2 = TestFilter.equal(.name, "Jane") + + let orFilter = TestFilter.or([nameFilter1, nameFilter2]) + let andFilter = TestFilter.and([ageFilter, orFilter]) + + let json = andFilter.toRawJSON() + + let expected: [String: RawJSON] = [ + "$and": .array([ + .dictionary(["age": .dictionary(["$gt": .number(18.0)])]), + .dictionary([ + "$or": .array([ + .dictionary(["name": .dictionary(["$eq": .string("John")])]), + .dictionary(["name": .dictionary(["$eq": .string("Jane")])]) + ]) + ]) + ]) + ] + + #expect(json == expected) + } + + // MARK: - URL Filter Tests + + @Test("URL filter value") + func uRLFilterValue() { + let url = URL(string: "https://example.com")! + let filter = TestFilter.equal(.email, url) + let json = filter.toRawJSON() + + let expected: [String: RawJSON] = [ + "email": .dictionary(["$eq": .string("https://example.com")]) + ] + + #expect(json == expected) + } + + // MARK: - Dictionary Filter Tests + + @Test("Dictionary filter value") + func dictionaryFilterValue() { + let customData: [String: RawJSON] = [ + "key1": .string("value1"), + "key2": .number(42.0) + ] + let filter = TestFilter.equal(.tags, customData) + let json = filter.toRawJSON() + + let expected: [String: RawJSON] = [ + "tags": .dictionary(["$eq": .dictionary(customData)]) + ] + + #expect(json == expected) + } + + // MARK: - Array Filter Tests + + @Test("Array filter value") + func arrayFilterValue() { + let arrayValue = ["item1", "item2"] + let filter = TestFilter.equal(.tags, arrayValue) + let json = filter.toRawJSON() + + let expected: [String: RawJSON] = [ + "tags": .dictionary(["$eq": .array([.string("item1"), .string("item2")])]) + ] + + #expect(json == expected) + } + + // MARK: - Edge Cases + + @Test("Empty array in filter") + func emptyArrayInFilter() { + let arrayValue: [String] = [] + let filter = TestFilter.in(.tags, arrayValue) + let json = filter.toRawJSON() + + let expected: [String: RawJSON] = [ + "tags": .dictionary(["$in": .array([])]) + ] + + #expect(json == expected) + } + + @Test("Empty and filter") + func emptyAndFilter() { + let subFilters: [TestFilter] = [] + let filter = TestFilter.and(subFilters) + let json = filter.toRawJSON() + + let expected: [String: RawJSON] = [ + "$and": .array([]) + ] + + #expect(json == expected) + } + + @Test("Empty or filter") + func emptyOrFilter() { + let subFilters: [TestFilter] = [] + let filter = TestFilter.or(subFilters) + let json = filter.toRawJSON() + + let expected: [String: RawJSON] = [ + "$or": .array([]) + ] + + #expect(json == expected) + } + + // MARK: - Local Filter Matching + + @Test func filterMatchingEqual() { + let ageFilter = TestFilter.equal(.age, 25) + #expect(ageFilter.matches(TestUser(age: 25))) + #expect(!ageFilter.matches(TestUser(age: 20))) + + let nameFilter = TestFilter.equal(.name, "John") + #expect(nameFilter.matches(TestUser(name: "John"))) + #expect(!nameFilter.matches(TestUser(name: "Jane"))) + + // Test case-sensitive string comparison + let caseSensitiveNameFilter = TestFilter.equal(.name, "John") + #expect(caseSensitiveNameFilter.matches(TestUser(name: "John"))) + #expect(!caseSensitiveNameFilter.matches(TestUser(name: "john"))) + #expect(!caseSensitiveNameFilter.matches(TestUser(name: "JOHN"))) + + // Test diacritic string comparison (case-sensitive) + let diacriticNameFilter = TestFilter.equal(.name, "José") + #expect(diacriticNameFilter.matches(TestUser(name: "José"))) + #expect(!diacriticNameFilter.matches(TestUser(name: "Jose"))) + #expect(!diacriticNameFilter.matches(TestUser(name: "josé"))) + + let emailFilter = TestFilter.equal(.email, "john@getstream.io") + #expect(emailFilter.matches(TestUser(email: "john@getstream.io"))) + #expect(!emailFilter.matches(TestUser(email: "jane@getstream.io"))) + + let tagsFilter = TestFilter.equal(.tags, ["orange", "yellow"]) + #expect(tagsFilter.matches(TestUser(tags: ["orange", "yellow"]))) + #expect(!tagsFilter.matches(TestUser(tags: ["red", "blue"]))) + + let isActiveFilter = TestFilter.equal(.isActive, true) + #expect(isActiveFilter.matches(TestUser(isActive: true))) + #expect(!isActiveFilter.matches(TestUser(isActive: false))) + + let testDate = Date(timeIntervalSince1970: 1_640_995_200) // 2022-01-01 00:00:00 UTC + let createdAtFilter = TestFilter.equal(.createdAt, testDate) + #expect(createdAtFilter.matches(TestUser(createdAt: testDate))) + #expect(!createdAtFilter.matches(TestUser(createdAt: Date(timeIntervalSince1970: 1_640_995_201)))) + + let heightFilter = TestFilter.equal(.height, 180.1) + #expect(heightFilter.matches(TestUser(height: 180.1))) + #expect(!heightFilter.matches(TestUser(height: 175.0))) + + let testURL = URL(string: "https://getstream.io")! + let homepageFilter = TestFilter.equal(.homepage, testURL) + #expect(homepageFilter.matches(TestUser(homepage: testURL))) + #expect(!homepageFilter.matches(TestUser(homepage: URL(string: "https://example.com")!))) + + let testSearchData: [String: RawJSON] = [ + "address": .dictionary([ + "country": .string("NL"), + "city": .string("Amsterdam") + ]) + ] + let searchDataFilter = TestFilter.equal(.searchData, testSearchData) + #expect(searchDataFilter.matches(TestUser(searchData: testSearchData))) + #expect(!searchDataFilter.matches(TestUser(searchData: [ + "address": .dictionary([ + "country": .string("US"), + "city": .string("New York") + ]) + ]))) + } + + @Test func filterMatchingIsGreater() { + // Test Int property (age) + let ageFilter = TestFilter.greater(.age, 25) + #expect(ageFilter.matches(TestUser(age: 30))) // 30 > 25 + #expect(ageFilter.matches(TestUser(age: 26))) // 26 > 25 + #expect(!ageFilter.matches(TestUser(age: 25))) // 25 == 25 (not greater) + #expect(!ageFilter.matches(TestUser(age: 20))) // 20 < 25 + + // Test Double property (height) + let heightFilter = TestFilter.greater(.height, 175.0) + #expect(heightFilter.matches(TestUser(height: 180.1))) // 180.1 > 175.0 + #expect(heightFilter.matches(TestUser(height: 176.0))) // 176.0 > 175.0 + #expect(!heightFilter.matches(TestUser(height: 175.0))) // 175.0 == 175.0 (not greater) + #expect(!heightFilter.matches(TestUser(height: 170.0))) // 170.0 < 175.0 + + // Test Date property (createdAt) + let testDate = Date(timeIntervalSince1970: 1_640_995_200) // 2022-01-01 00:00:00 UTC + let laterDate = Date(timeIntervalSince1970: 1_640_995_201) // 2022-01-01 00:00:01 UTC + let evenLaterDate = Date(timeIntervalSince1970: 1_640_995_300) // 2022-01-01 00:01:40 UTC + let earlierDate = Date(timeIntervalSince1970: 1_640_995_199) // 2021-12-31 23:59:59 UTC + + let createdAtFilter = TestFilter.greater(.createdAt, testDate) + #expect(createdAtFilter.matches(TestUser(createdAt: laterDate))) // laterDate > testDate + #expect(createdAtFilter.matches(TestUser(createdAt: evenLaterDate))) // evenLaterDate > testDate + #expect(!createdAtFilter.matches(TestUser(createdAt: testDate))) // testDate == testDate (not greater) + #expect(!createdAtFilter.matches(TestUser(createdAt: earlierDate))) // earlierDate < testDate + + // Test lexicographic string comparison + let lexicographicNameFilter = TestFilter.greater(.name, "John") + #expect(lexicographicNameFilter.matches(TestUser(name: "Johnny"))) // "Johnny" > "John" (lexicographic) + #expect(lexicographicNameFilter.matches(TestUser(name: "Mike"))) // "Mike" > "John" (lexicographic) + #expect(lexicographicNameFilter.matches(TestUser(name: "john"))) // "john" > "John" (lexicographic: lowercase > uppercase) + #expect(!lexicographicNameFilter.matches(TestUser(name: "John"))) // "John" == "John" (not greater) + #expect(!lexicographicNameFilter.matches(TestUser(name: "JOHN"))) // "JOHN" < "John" (lexicographic: uppercase < lowercase) + #expect(!lexicographicNameFilter.matches(TestUser(name: "Alice"))) // "Alice" < "John" (lexicographic) + + // Test diacritic string comparison (lexicographic) + let diacriticNameFilter = TestFilter.greater(.name, "José") + #expect(diacriticNameFilter.matches(TestUser(name: "Joséa"))) // "Joséa" > "José" (lexicographic) + #expect(diacriticNameFilter.matches(TestUser(name: "joséa"))) // "joséa" > "José" (lexicographic: lowercase > uppercase) + #expect(!diacriticNameFilter.matches(TestUser(name: "José"))) // "José" == "José" (not greater) + #expect(!diacriticNameFilter.matches(TestUser(name: "Jose"))) // "Jose" < "José" (no accent) + #expect(diacriticNameFilter.matches(TestUser(name: "jose"))) // "jose" > "José" (lexicographic: lowercase > uppercase) + } + + @Test func filterMatchingIsGreaterOrEqual() { + // Test Int property (age) + let ageFilter = TestFilter.greaterOrEqual(.age, 25) + #expect(ageFilter.matches(TestUser(age: 30))) // 30 >= 25 + #expect(ageFilter.matches(TestUser(age: 26))) // 26 >= 25 + #expect(ageFilter.matches(TestUser(age: 25))) // 25 >= 25 + #expect(!ageFilter.matches(TestUser(age: 20))) // 20 < 25 + + // Test Double property (height) + let heightFilter = TestFilter.greaterOrEqual(.height, 175.0) + #expect(heightFilter.matches(TestUser(height: 180.1))) // 180.1 >= 175.0 + #expect(heightFilter.matches(TestUser(height: 176.0))) // 176.0 >= 175.0 + #expect(heightFilter.matches(TestUser(height: 175.0))) // 175.0 >= 175.0 + #expect(!heightFilter.matches(TestUser(height: 170.0))) // 170.0 < 175.0 + + // Test Date property (createdAt) + let testDate = Date(timeIntervalSince1970: 1_640_995_200) // 2022-01-01 00:00:00 UTC + let laterDate = Date(timeIntervalSince1970: 1_640_995_201) // 2022-01-01 00:00:01 UTC + let evenLaterDate = Date(timeIntervalSince1970: 1_640_995_300) // 2022-01-01 00:01:40 UTC + let earlierDate = Date(timeIntervalSince1970: 1_640_995_199) // 2021-12-31 23:59:59 UTC + + let createdAtFilter = TestFilter.greaterOrEqual(.createdAt, testDate) + #expect(createdAtFilter.matches(TestUser(createdAt: laterDate))) // laterDate >= testDate + #expect(createdAtFilter.matches(TestUser(createdAt: evenLaterDate))) // evenLaterDate >= testDate + #expect(createdAtFilter.matches(TestUser(createdAt: testDate))) // testDate >= testDate + #expect(!createdAtFilter.matches(TestUser(createdAt: earlierDate))) // earlierDate < testDate + + // Test lexicographic string comparison + let lexicographicNameFilter = TestFilter.greaterOrEqual(.name, "John") + #expect(lexicographicNameFilter.matches(TestUser(name: "Johnny"))) // "Johnny" > "John" (lexicographic) + #expect(lexicographicNameFilter.matches(TestUser(name: "Mike"))) // "Mike" > "John" (lexicographic) + #expect(lexicographicNameFilter.matches(TestUser(name: "john"))) // "john" > "John" (lexicographic: lowercase > uppercase) + #expect(lexicographicNameFilter.matches(TestUser(name: "John"))) // "John" == "John" (equal) + #expect(!lexicographicNameFilter.matches(TestUser(name: "JOHN"))) // "JOHN" < "John" (lexicographic: uppercase < lowercase) + #expect(!lexicographicNameFilter.matches(TestUser(name: "Alice"))) // "Alice" < "John" (lexicographic) + + // Test diacritic string comparison (lexicographic) + let diacriticNameFilter = TestFilter.greaterOrEqual(.name, "José") + #expect(diacriticNameFilter.matches(TestUser(name: "Joséa"))) // "Joséa" > "José" (lexicographic) + #expect(diacriticNameFilter.matches(TestUser(name: "joséa"))) // "joséa" > "José" (lexicographic: lowercase > uppercase) + #expect(diacriticNameFilter.matches(TestUser(name: "José"))) // "José" == "José" (equal) + #expect(!diacriticNameFilter.matches(TestUser(name: "Jose"))) // "Jose" < "José" (no accent) + #expect(diacriticNameFilter.matches(TestUser(name: "jose"))) // "jose" > "José" (lexicographic: lowercase > uppercase) + } + + @Test func filterMatchingIsLess() { + // Test Int property (age) + let ageFilter = TestFilter.less(.age, 25) + #expect(ageFilter.matches(TestUser(age: 20))) // 20 < 25 + #expect(ageFilter.matches(TestUser(age: 24))) // 24 < 25 + #expect(!ageFilter.matches(TestUser(age: 25))) // 25 == 25 (not less) + #expect(!ageFilter.matches(TestUser(age: 30))) // 30 > 25 + + // Test Double property (height) + let heightFilter = TestFilter.less(.height, 175.0) + #expect(heightFilter.matches(TestUser(height: 170.0))) // 170.0 < 175.0 + #expect(heightFilter.matches(TestUser(height: 174.9))) // 174.9 < 175.0 + #expect(!heightFilter.matches(TestUser(height: 175.0))) // 175.0 == 175.0 (not less) + #expect(!heightFilter.matches(TestUser(height: 180.1))) // 180.1 > 175.0 + + // Test Date property (createdAt) + let testDate = Date(timeIntervalSince1970: 1_640_995_200) // 2022-01-01 00:00:00 UTC + let laterDate = Date(timeIntervalSince1970: 1_640_995_201) // 2022-01-01 00:00:01 UTC + let evenLaterDate = Date(timeIntervalSince1970: 1_640_995_300) // 2022-01-01 00:01:40 UTC + let earlierDate = Date(timeIntervalSince1970: 1_640_995_199) // 2021-12-31 23:59:59 UTC + + let createdAtFilter = TestFilter.less(.createdAt, testDate) + #expect(createdAtFilter.matches(TestUser(createdAt: earlierDate))) // earlierDate < testDate + #expect(!createdAtFilter.matches(TestUser(createdAt: testDate))) // testDate == testDate (not less) + #expect(!createdAtFilter.matches(TestUser(createdAt: laterDate))) // laterDate > testDate + #expect(!createdAtFilter.matches(TestUser(createdAt: evenLaterDate))) // evenLaterDate > testDate + + // Test lexicographic string comparison + let lexicographicNameFilter = TestFilter.less(.name, "John") + #expect(!lexicographicNameFilter.matches(TestUser(name: "Johnny"))) // "Johnny" > "John" (not less) + #expect(!lexicographicNameFilter.matches(TestUser(name: "Mike"))) // "Mike" > "John" (not less) + #expect(!lexicographicNameFilter.matches(TestUser(name: "john"))) // "john" > "John" (not less: lowercase > uppercase) + #expect(!lexicographicNameFilter.matches(TestUser(name: "John"))) // "John" == "John" (not less) + #expect(lexicographicNameFilter.matches(TestUser(name: "JOHN"))) // "JOHN" < "John" (lexicographic: uppercase < lowercase) + #expect(lexicographicNameFilter.matches(TestUser(name: "Alice"))) // "Alice" < "John" (lexicographic) + + // Test diacritic string comparison (lexicographic) + let diacriticNameFilter = TestFilter.less(.name, "José") + #expect(!diacriticNameFilter.matches(TestUser(name: "Joséa"))) // "Joséa" > "José" (not less) + #expect(!diacriticNameFilter.matches(TestUser(name: "joséa"))) // "joséa" > "José" (not less: lowercase > uppercase) + #expect(!diacriticNameFilter.matches(TestUser(name: "José"))) // "José" == "José" (not less) + #expect(diacriticNameFilter.matches(TestUser(name: "Jose"))) // "Jose" < "José" (no accent) + #expect(!diacriticNameFilter.matches(TestUser(name: "jose"))) // "jose" > "José" (not less: lowercase > uppercase) + } + + @Test func filterMatchingIsLessOrEqual() { + // Test Int property (age) + let ageFilter = TestFilter.lessOrEqual(.age, 25) + #expect(ageFilter.matches(TestUser(age: 20))) // 20 <= 25 + #expect(ageFilter.matches(TestUser(age: 24))) // 24 <= 25 + #expect(ageFilter.matches(TestUser(age: 25))) // 25 <= 25 + #expect(!ageFilter.matches(TestUser(age: 30))) // 30 > 25 + + // Test Double property (height) + let heightFilter = TestFilter.lessOrEqual(.height, 175.0) + #expect(heightFilter.matches(TestUser(height: 170.0))) // 170.0 <= 175.0 + #expect(heightFilter.matches(TestUser(height: 174.9))) // 174.9 <= 175.0 + #expect(heightFilter.matches(TestUser(height: 175.0))) // 175.0 <= 175.0 + #expect(!heightFilter.matches(TestUser(height: 180.1))) // 180.1 > 175.0 + + // Test Date property (createdAt) + let testDate = Date(timeIntervalSince1970: 1_640_995_200) // 2022-01-01 00:00:00 UTC + let laterDate = Date(timeIntervalSince1970: 1_640_995_201) // 2022-01-01 00:00:01 UTC + let evenLaterDate = Date(timeIntervalSince1970: 1_640_995_300) // 2022-01-01 00:01:40 UTC + let earlierDate = Date(timeIntervalSince1970: 1_640_995_199) // 2021-12-31 23:59:59 UTC + + let createdAtFilter = TestFilter.lessOrEqual(.createdAt, testDate) + #expect(createdAtFilter.matches(TestUser(createdAt: earlierDate))) // earlierDate <= testDate + #expect(createdAtFilter.matches(TestUser(createdAt: testDate))) // testDate <= testDate + #expect(!createdAtFilter.matches(TestUser(createdAt: laterDate))) // laterDate > testDate + #expect(!createdAtFilter.matches(TestUser(createdAt: evenLaterDate))) // evenLaterDate > testDate + + // Test lexicographic string comparison + let lexicographicNameFilter = TestFilter.lessOrEqual(.name, "John") + #expect(!lexicographicNameFilter.matches(TestUser(name: "Johnny"))) // "Johnny" > "John" (not less or equal) + #expect(!lexicographicNameFilter.matches(TestUser(name: "Mike"))) // "Mike" > "John" (not less or equal) + #expect(!lexicographicNameFilter.matches(TestUser(name: "john"))) // "john" > "John" (not less or equal: lowercase > uppercase) + #expect(lexicographicNameFilter.matches(TestUser(name: "John"))) // "John" == "John" (equal) + #expect(lexicographicNameFilter.matches(TestUser(name: "JOHN"))) // "JOHN" < "John" (lexicographic: uppercase < lowercase) + #expect(lexicographicNameFilter.matches(TestUser(name: "Alice"))) // "Alice" < "John" (lexicographic) + + // Test diacritic string comparison (lexicographic) + let diacriticNameFilter = TestFilter.lessOrEqual(.name, "José") + #expect(!diacriticNameFilter.matches(TestUser(name: "Joséa"))) // "Joséa" > "José" (not less or equal) + #expect(!diacriticNameFilter.matches(TestUser(name: "joséa"))) // "joséa" > "José" (not less or equal: lowercase > uppercase) + #expect(diacriticNameFilter.matches(TestUser(name: "José"))) // "José" == "José" (equal) + #expect(diacriticNameFilter.matches(TestUser(name: "Jose"))) // "Jose" < "José" (no accent) + #expect(!diacriticNameFilter.matches(TestUser(name: "jose"))) // "jose" > "José" (not less or equal: lowercase > uppercase) + } + + @Test func filterMatchingIn() { + // Test Int property (age) with array of integers + let ageFilter = TestFilter.in(.age, [18, 21, 25, 30]) + #expect(ageFilter.matches(TestUser(age: 18))) // 18 is in [18, 21, 25, 30] + #expect(ageFilter.matches(TestUser(age: 21))) // 21 is in [18, 21, 25, 30] + #expect(ageFilter.matches(TestUser(age: 25))) // 25 is in [18, 21, 25, 30] + #expect(ageFilter.matches(TestUser(age: 30))) // 30 is in [18, 21, 25, 30] + #expect(!ageFilter.matches(TestUser(age: 20))) // 20 is not in [18, 21, 25, 30] + #expect(!ageFilter.matches(TestUser(age: 35))) // 35 is not in [18, 21, 25, 30] + + // Test Double property (height) with array of doubles + let heightFilter = TestFilter.in(.height, [170.0, 175.0, 180.1, 185.0]) + #expect(heightFilter.matches(TestUser(height: 170.0))) // 170.0 is in [170.0, 175.0, 180.1, 185.0] + #expect(heightFilter.matches(TestUser(height: 175.0))) // 175.0 is in [170.0, 175.0, 180.1, 185.0] + #expect(heightFilter.matches(TestUser(height: 180.1))) // 180.1 is in [170.0, 175.0, 180.1, 185.0] + #expect(heightFilter.matches(TestUser(height: 185.0))) // 185.0 is in [170.0, 175.0, 180.1, 185.0] + #expect(!heightFilter.matches(TestUser(height: 172.5))) // 172.5 is not in [170.0, 175.0, 180.1, 185.0] + #expect(!heightFilter.matches(TestUser(height: 190.0))) // 190.0 is not in [170.0, 175.0, 180.1, 185.0] + + // Test String property (name) with array of strings + let nameFilter = TestFilter.in(.name, ["John", "Jane", "Bob", "Alice"]) + #expect(nameFilter.matches(TestUser(name: "John"))) // "John" is in ["John", "Jane", "Bob", "Alice"] + #expect(nameFilter.matches(TestUser(name: "Jane"))) // "Jane" is in ["John", "Jane", "Bob", "Alice"] + #expect(nameFilter.matches(TestUser(name: "Bob"))) // "Bob" is in ["John", "Jane", "Bob", "Alice"] + #expect(nameFilter.matches(TestUser(name: "Alice"))) // "Alice" is in ["John", "Jane", "Bob", "Alice"] + #expect(!nameFilter.matches(TestUser(name: "Mike"))) // "Mike" is not in ["John", "Jane", "Bob", "Alice"] + #expect(!nameFilter.matches(TestUser(name: "Sarah"))) // "Sarah" is not in ["John", "Jane", "Bob", "Alice"] + + // Test case-sensitive string comparison + let caseSensitiveNameFilter = TestFilter.in(.name, ["John", "Jane", "Bob"]) + #expect(caseSensitiveNameFilter.matches(TestUser(name: "John"))) // "John" is in ["John", "Jane", "Bob"] + #expect(caseSensitiveNameFilter.matches(TestUser(name: "Jane"))) // "Jane" is in ["John", "Jane", "Bob"] + #expect(caseSensitiveNameFilter.matches(TestUser(name: "Bob"))) // "Bob" is in ["John", "Jane", "Bob"] + #expect(!caseSensitiveNameFilter.matches(TestUser(name: "john"))) // "john" (lowercase) is not in the array + #expect(!caseSensitiveNameFilter.matches(TestUser(name: "JOHN"))) // "JOHN" (uppercase) is not in the array + + // Test diacritic string comparison (case-sensitive) + let diacriticNameFilter = TestFilter.in(.name, ["José", "François", "Müller"]) + #expect(diacriticNameFilter.matches(TestUser(name: "José"))) // "José" is in ["José", "François", "Müller"] + #expect(diacriticNameFilter.matches(TestUser(name: "François"))) // "François" is in ["José", "François", "Müller"] + #expect(diacriticNameFilter.matches(TestUser(name: "Müller"))) // "Müller" is in ["José", "François", "Müller"] + #expect(!diacriticNameFilter.matches(TestUser(name: "Jose"))) // "Jose" (no accent) is not in the array + #expect(!diacriticNameFilter.matches(TestUser(name: "josé"))) // "josé" (lowercase with accent) is not in the array + #expect(!diacriticNameFilter.matches(TestUser(name: "Francois"))) // "Francois" (no accent) is not in the array + + // Test Bool property (isActive) with array of booleans + let isActiveFilter = TestFilter.in(.isActive, [true, false]) + #expect(isActiveFilter.matches(TestUser(isActive: true))) // true is in [true, false] + #expect(isActiveFilter.matches(TestUser(isActive: false))) // false is in [true, false] + + // Test Array property (tags) with array of string arrays + let tagsFilter = TestFilter.in(.tags, [["orange", "yellow"], ["red", "blue"], ["green", "purple"]]) + #expect(tagsFilter.matches(TestUser(tags: ["orange", "yellow"]))) // ["orange", "yellow"] is in the array + #expect(tagsFilter.matches(TestUser(tags: ["red", "blue"]))) // ["red", "blue"] is in the array + #expect(tagsFilter.matches(TestUser(tags: ["green", "purple"]))) // ["green", "purple"] is in the array + #expect(!tagsFilter.matches(TestUser(tags: ["black", "white"]))) // ["black", "white"] is not in the array + #expect(!tagsFilter.matches(TestUser(tags: ["orange"]))) // ["orange"] is not in the array (partial match) + } + + @Test func filterMatchingExists() { + // Test exists: true - checking if properties exist + let nameExistsFilter = TestFilter.exists(.name, true) + #expect(nameExistsFilter.matches(TestUser(name: "John"))) // name property exists + #expect(nameExistsFilter.matches(TestUser(name: "Jane"))) // name property exists + #expect(nameExistsFilter.matches(TestUser(name: ""))) // name property exists even if empty string + + let ageExistsFilter = TestFilter.exists(.age, true) + #expect(ageExistsFilter.matches(TestUser(age: 25))) // age property exists + #expect(ageExistsFilter.matches(TestUser(age: 0))) // age property exists even if 0 + + let heightExistsFilter = TestFilter.exists(.height, true) + #expect(heightExistsFilter.matches(TestUser(height: 180.1))) // height property exists + #expect(heightExistsFilter.matches(TestUser(height: 0.0))) // height property exists even if 0.0 + + let tagsExistsFilter = TestFilter.exists(.tags, true) + #expect(tagsExistsFilter.matches(TestUser(tags: ["orange", "yellow"]))) // tags property exists + #expect(tagsExistsFilter.matches(TestUser(tags: []))) // tags property exists even if empty array + + let isActiveExistsFilter = TestFilter.exists(.isActive, true) + #expect(isActiveExistsFilter.matches(TestUser(isActive: true))) // isActive property exists + #expect(isActiveExistsFilter.matches(TestUser(isActive: false))) // isActive property exists even if false + + // Test exists: false - checking if properties don't exist + // Note: Since TestUser always has these properties, we can't easily test exists: false + // In a real scenario, this would be used with optional properties or properties that might be nil + let nameNotExistsFilter = TestFilter.exists(.name, false) + #expect(!nameNotExistsFilter.matches(TestUser(name: "John"))) // name property exists, so exists: false should not match + + let ageNotExistsFilter = TestFilter.exists(.age, false) + #expect(!ageNotExistsFilter.matches(TestUser(age: 25))) // age property exists, so exists: false should not match + + let heightNotExistsFilter = TestFilter.exists(.height, false) + #expect(!heightNotExistsFilter.matches(TestUser(height: 180.1))) // height property exists, so exists: false should not match + + let tagsNotExistsFilter = TestFilter.exists(.tags, false) + #expect(!tagsNotExistsFilter.matches(TestUser(tags: ["orange", "yellow"]))) // tags property exists, so exists: false should not match + + let isActiveNotExistsFilter = TestFilter.exists(.isActive, false) + #expect(!isActiveNotExistsFilter.matches(TestUser(isActive: true))) // isActive property exists, so exists: false should not match + #expect(!isActiveNotExistsFilter.matches(TestUser(isActive: false))) // isActive property exists, so exists: false should not match + } + + @Test func filterMatchingQuery() { + // Test case-insensitive full text search with name property + // $q should match substrings anywhere in the text (not just from beginning) + let nameQueryFilter = TestFilter.query(.name, "john") + #expect(nameQueryFilter.matches(TestUser(name: "John"))) // "John" contains "john" (case-insensitive) + #expect(nameQueryFilter.matches(TestUser(name: "JOHN"))) // "JOHN" contains "john" (case-insensitive) + #expect(nameQueryFilter.matches(TestUser(name: "john"))) // "john" contains "john" (exact match) + #expect(nameQueryFilter.matches(TestUser(name: "Johnny"))) // "Johnny" contains "john" (case-insensitive) + #expect(nameQueryFilter.matches(TestUser(name: "JOHNNY"))) // "JOHNNY" contains "john" (case-insensitive) + #expect(!nameQueryFilter.matches(TestUser(name: "Jane"))) // "Jane" does not contain "john" + #expect(!nameQueryFilter.matches(TestUser(name: "Bob"))) // "Bob" does not contain "john" + + // Test diacritic string comparison + let diacriticQueryFilter = TestFilter.query(.name, "josé") + #expect(diacriticQueryFilter.matches(TestUser(name: "José"))) // "José" contains "josé" (case-insensitive) + #expect(diacriticQueryFilter.matches(TestUser(name: "JOSÉ"))) // "JOSÉ" contains "josé" (case-insensitive) + #expect(diacriticQueryFilter.matches(TestUser(name: "josé"))) // "josé" contains "josé" (exact match) + #expect(!diacriticQueryFilter.matches(TestUser(name: "Jose"))) // "Jose" (no accent) does not contain "josé" + #expect(!diacriticQueryFilter.matches(TestUser(name: "jose"))) // "jose" (no accent) does not contain "josé" + + // Test case-insensitive full text search with email property + let emailQueryFilter = TestFilter.query(.email, "GETSTREAM") + #expect(emailQueryFilter.matches(TestUser(email: "john@getstream.io"))) // "john@getstream.io" contains "GETSTREAM" (case-insensitive) + #expect(emailQueryFilter.matches(TestUser(email: "jane@GETSTREAM.io"))) // "jane@GETSTREAM.io" contains "GETSTREAM" (case-insensitive) + #expect(emailQueryFilter.matches(TestUser(email: "admin@GetStream.com"))) // "admin@GetStream.com" contains "GETSTREAM" (case-insensitive) + #expect(!emailQueryFilter.matches(TestUser(email: "john@example.com"))) // "john@example.com" does not contain "GETSTREAM" + #expect(!emailQueryFilter.matches(TestUser(email: "user@other.io"))) // "user@other.io" does not contain "GETSTREAM" + + // Test full text search with middle substring matching + // $q should find substrings anywhere in the text, not just at the beginning + let middleQueryFilter = TestFilter.query(.name, "hn") + #expect(middleQueryFilter.matches(TestUser(name: "John"))) // "John" contains "hn" in the middle + #expect(middleQueryFilter.matches(TestUser(name: "JOHN"))) // "JOHN" contains "hn" in the middle + #expect(middleQueryFilter.matches(TestUser(name: "john"))) // "john" contains "hn" in the middle + #expect(!middleQueryFilter.matches(TestUser(name: "Jane"))) // "Jane" does not contain "hn" + #expect(!middleQueryFilter.matches(TestUser(name: "Bob"))) // "Bob" does not contain "hn" + + // Test full text search with end substring matching + let endQueryFilter = TestFilter.query(.name, "ny") + #expect(endQueryFilter.matches(TestUser(name: "Johnny"))) // "Johnny" contains "ny" at the end + #expect(endQueryFilter.matches(TestUser(name: "JOHNNY"))) // "JOHNNY" contains "ny" at the end + #expect(!endQueryFilter.matches(TestUser(name: "John"))) // "John" does not contain "ny" + #expect(!endQueryFilter.matches(TestUser(name: "Jane"))) // "Jane" does not contain "ny" + + // Test full text search with partial word matching + let partialQueryFilter = TestFilter.query(.name, "jo") + #expect(partialQueryFilter.matches(TestUser(name: "John"))) // "John" contains "jo" at the beginning + #expect(partialQueryFilter.matches(TestUser(name: "JOHN"))) // "JOHN" contains "jo" at the beginning + #expect(partialQueryFilter.matches(TestUser(name: "john"))) // "john" contains "jo" at the beginning + #expect(partialQueryFilter.matches(TestUser(name: "Johnny"))) // "Johnny" contains "jo" at the beginning + #expect(!partialQueryFilter.matches(TestUser(name: "Jane"))) // "Jane" does not contain "jo" + #expect(!partialQueryFilter.matches(TestUser(name: "Bob"))) // "Bob" does not contain "jo" + } + + @Test func filterMatchingAutocomplete() { + // Test PostgreSQL-style full-text search autocomplete with name property + // $autocomplete should match at word boundaries (PostgreSQL-style) + let nameAutocompleteFilter = TestFilter.autocomplete(.name, "jo") + #expect(nameAutocompleteFilter.matches(TestUser(name: "John"))) // "John" starts with "jo" (case-insensitive) + #expect(nameAutocompleteFilter.matches(TestUser(name: "JOHN"))) // "JOHN" starts with "jo" (case-insensitive) + #expect(nameAutocompleteFilter.matches(TestUser(name: "john"))) // "john" starts with "jo" (case-insensitive) + #expect(nameAutocompleteFilter.matches(TestUser(name: "Johnny"))) // "Johnny" starts with "jo" (case-insensitive) + #expect(nameAutocompleteFilter.matches(TestUser(name: "JOHNNY"))) // "JOHNNY" starts with "jo" (case-insensitive) + #expect(!nameAutocompleteFilter.matches(TestUser(name: "Jane"))) // "Jane" does not start with "jo" + #expect(!nameAutocompleteFilter.matches(TestUser(name: "Bob"))) // "Bob" does not start with "jo" + + // Test diacritic string comparison + let diacriticAutocompleteFilter = TestFilter.autocomplete(.name, "jos") + #expect(diacriticAutocompleteFilter.matches(TestUser(name: "José"))) // "José" starts with "jos" (case-insensitive) + #expect(diacriticAutocompleteFilter.matches(TestUser(name: "JOSÉ"))) // "JOSÉ" starts with "jos" (case-insensitive) + #expect(diacriticAutocompleteFilter.matches(TestUser(name: "josé"))) // "josé" starts with "jos" (case-insensitive) + + // Test case-insensitive autocomplete with email property + let emailAutocompleteFilter = TestFilter.autocomplete(.email, "JOHN") + #expect(emailAutocompleteFilter.matches(TestUser(email: "john@getstream.io"))) // "john@getstream.io" starts with "JOHN" (case-insensitive) + #expect(emailAutocompleteFilter.matches(TestUser(email: "JOHN@getstream.io"))) // "JOHN@getstream.io" starts with "JOHN" (case-insensitive) + #expect(emailAutocompleteFilter.matches(TestUser(email: "john@example.com"))) // "john@example.com" starts with "JOHN" (case-insensitive) + #expect(!emailAutocompleteFilter.matches(TestUser(email: "jane@getstream.io"))) // "jane@getstream.io" does not start with "JOHN" + #expect(!emailAutocompleteFilter.matches(TestUser(email: "admin@getstream.io"))) // "admin@getstream.io" does not start with "JOHN" + + // Test autocomplete with single character + let singleCharFilter = TestFilter.autocomplete(.name, "j") + #expect(singleCharFilter.matches(TestUser(name: "John"))) // "John" starts with "j" (case-insensitive) + #expect(singleCharFilter.matches(TestUser(name: "JOHN"))) // "JOHN" starts with "j" (case-insensitive) + #expect(singleCharFilter.matches(TestUser(name: "john"))) // "john" starts with "j" (case-insensitive) + #expect(singleCharFilter.matches(TestUser(name: "Johnny"))) // "Johnny" starts with "j" (case-insensitive) + #expect(singleCharFilter.matches(TestUser(name: "Jane"))) // "Jane" starts with "j" + #expect(!singleCharFilter.matches(TestUser(name: "Bob"))) // "Bob" does not start with "j" + + // Test autocomplete with longer prefix + let longPrefixFilter = TestFilter.autocomplete(.name, "john") + #expect(longPrefixFilter.matches(TestUser(name: "John"))) // "John" starts with "john" (case-insensitive) + #expect(longPrefixFilter.matches(TestUser(name: "JOHN"))) // "JOHN" starts with "john" (case-insensitive) + #expect(longPrefixFilter.matches(TestUser(name: "john"))) // "john" starts with "john" (case-insensitive) + #expect(longPrefixFilter.matches(TestUser(name: "Johnny"))) // "Johnny" starts with "john" (case-insensitive) + #expect(!longPrefixFilter.matches(TestUser(name: "Jane"))) // "Jane" does not start with "john" + #expect(!longPrefixFilter.matches(TestUser(name: "Bob"))) // "Bob" does not start with "john" + + // Test autocomplete with middle substring (should NOT match) + // $autocomplete is anchored to the beginning, so middle substrings should not match + let middleSubstringFilter = TestFilter.autocomplete(.name, "hn") + #expect(!middleSubstringFilter.matches(TestUser(name: "John"))) // "John" does not start with "hn" + #expect(!middleSubstringFilter.matches(TestUser(name: "JOHN"))) // "JOHN" does not start with "hn" + #expect(!middleSubstringFilter.matches(TestUser(name: "john"))) // "john" does not start with "hn" + + // Test autocomplete with end substring (should NOT match) + let endSubstringFilter = TestFilter.autocomplete(.name, "ny") + #expect(!endSubstringFilter.matches(TestUser(name: "Johnny"))) // "Johnny" does not start with "ny" + #expect(!endSubstringFilter.matches(TestUser(name: "JOHNNY"))) // "JOHNNY" does not start with "ny" + + // Test PostgreSQL-style word boundary matching with multi-word text + let multiWordFilter = TestFilter.autocomplete(.name, "john") + #expect(multiWordFilter.matches(TestUser(name: "John Smith"))) // "John Smith" - "John" starts with "john" + #expect(multiWordFilter.matches(TestUser(name: "JOHN DOE"))) // "JOHN DOE" - "JOHN" starts with "john" + #expect(multiWordFilter.matches(TestUser(name: "john-doe"))) // "john-doe" - "john" starts with "john" + #expect(multiWordFilter.matches(TestUser(name: "john.doe"))) // "john.doe" - "john" starts with "john" + #expect(multiWordFilter.matches(TestUser(name: "Smith John"))) // "Smith John" - "John" starts with "john" (case insensitive) + #expect(multiWordFilter.matches(TestUser(name: "Johnson"))) // "Johnson" - "Johnson" starts with "john" (case insensitive) + + // Test word boundary matching with partial words + let partialWordFilter = TestFilter.autocomplete(.name, "smi") + #expect(partialWordFilter.matches(TestUser(name: "John Smith"))) // "John Smith" - "Smith" starts with "smi" + #expect(partialWordFilter.matches(TestUser(name: "SMITH JOHN"))) // "SMITH JOHN" - "SMITH" starts with "smi" + #expect(partialWordFilter.matches(TestUser(name: "smith-jones"))) // "smith-jones" - "smith" starts with "smi" + #expect(!partialWordFilter.matches(TestUser(name: "Johnson"))) // "Johnson" - "Johnson" does not start with "smi" + + // Test case-insensitive word boundary matching + let caseInsensitiveFilter = TestFilter.autocomplete(.name, "JANE") + #expect(caseInsensitiveFilter.matches(TestUser(name: "Jane Doe"))) // "Jane Doe" - "Jane" starts with "JANE" (case-insensitive) + #expect(caseInsensitiveFilter.matches(TestUser(name: "jane smith"))) // "jane smith" - "jane" starts with "JANE" (case-insensitive) + #expect(caseInsensitiveFilter.matches(TestUser(name: "JANE DOE"))) // "JANE DOE" - "JANE" starts with "JANE" + #expect(caseInsensitiveFilter.matches(TestUser(name: "John Jane"))) // "John Jane" - Second word starts with "JANE" + + // Test punctuation handling in word boundaries + let punctuationFilter = TestFilter.autocomplete(.name, "o'connor") + #expect(punctuationFilter.matches(TestUser(name: "O'Connor Smith"))) // "O'Connor Smith" - "O'Connor" starts with "o'connor" + #expect(punctuationFilter.matches(TestUser(name: "o'connor-jones"))) // "o'connor-jones" - "o'connor" starts with "o'connor" + #expect(!punctuationFilter.matches(TestUser(name: "Connor O'Brien"))) // "Connor O'Brien" - "Connor" does not start with "o'connor" + + // Test empty query (should not match) + let emptyQueryFilter = TestFilter.autocomplete(.name, "") + #expect(!emptyQueryFilter.matches(TestUser(name: "John"))) // Empty query should not match anything + + // Test single character word boundary matching + let singleCharWordFilter = TestFilter.autocomplete(.name, "a") + #expect(singleCharWordFilter.matches(TestUser(name: "Alice Smith"))) // "Alice Smith" - "Alice" starts with "a" + #expect(singleCharWordFilter.matches(TestUser(name: "John Adams"))) // "John Adams" - "Adams" starts with "a" + #expect(!singleCharWordFilter.matches(TestUser(name: "John Smith"))) // "John Smith" - no word starts with "a" + } + + @Test func filterMatchingContains() { + // Test contains filter with array property (tags) - single value + let tagsContainsFilter = TestFilter.contains(.tags, "orange") + #expect(tagsContainsFilter.matches(TestUser(tags: ["orange", "yellow"]))) // ["orange", "yellow"] contains "orange" + #expect(tagsContainsFilter.matches(TestUser(tags: ["orange"]))) // ["orange"] contains "orange" + #expect(tagsContainsFilter.matches(TestUser(tags: ["red", "orange", "blue"]))) // ["red", "orange", "blue"] contains "orange" + #expect(!tagsContainsFilter.matches(TestUser(tags: ["red", "blue"]))) // ["red", "blue"] does not contain "orange" + #expect(!tagsContainsFilter.matches(TestUser(tags: []))) // [] does not contain "orange" + + // Test contains filter with array property (tags) - array of values + let tagsArrayContainsFilter = TestFilter.contains(.tags, ["orange", "yellow"]) + #expect(tagsArrayContainsFilter.matches(TestUser(tags: ["orange", "yellow", "red"]))) // ["orange", "yellow", "red"] contains ["orange", "yellow"] + #expect(tagsArrayContainsFilter.matches(TestUser(tags: ["red", "orange", "yellow", "blue"]))) // ["red", "orange", "yellow", "blue"] contains ["orange", "yellow"] + #expect(!tagsArrayContainsFilter.matches(TestUser(tags: ["orange", "red"]))) // ["orange", "red"] does not contain ["orange", "yellow"] + #expect(!tagsArrayContainsFilter.matches(TestUser(tags: ["yellow"]))) // ["yellow"] does not contain ["orange", "yellow"] + #expect(!tagsArrayContainsFilter.matches(TestUser(tags: []))) // [] does not contain ["orange", "yellow"] + + // Test contains filter with dictionary property (searchData) + let searchDataContainsFilter = TestFilter.contains(.searchData, [ + "address": .dictionary([ + "country": .string("NL"), + "city": .string("Amsterdam") + ]) + ]) + #expect(searchDataContainsFilter.matches(TestUser(searchData: [ + "address": .dictionary([ + "country": .string("NL"), + "city": .string("Amsterdam") + ]) + ]))) // searchData exact match + + #expect(searchDataContainsFilter.matches(TestUser(searchData: [ + "address": .dictionary([ + "country": .string("NL"), + "city": .string("Amsterdam"), + "street": .string("Kleine-Gartmanplantsoen 21-6") + ]) + ]))) // searchData contains the specified address dictionary + + #expect(!searchDataContainsFilter.matches(TestUser(searchData: [ + "address": .dictionary([ + "country": .string("US"), + "city": .string("New York") + ]) + ]))) // searchData does not contain the specified address dictionary + + #expect(!searchDataContainsFilter.matches(TestUser(searchData: [ + "other_field": .string("value") + ]))) // searchData does not contain the address field at all + + // Test contains filter with nested dictionary + let nestedDictContainsFilter = TestFilter.contains(.searchData, [ + "address": .dictionary([ + "country": .string("NL") + ]) + ]) + #expect(nestedDictContainsFilter.matches(TestUser(searchData: [ + "address": .dictionary([ + "country": .string("NL"), + "city": .string("Amsterdam") + ]) + ]))) // searchData contains the nested country field + + #expect(!nestedDictContainsFilter.matches(TestUser(searchData: [ + "address": .dictionary([ + "country": .string("US") + ]) + ]))) // searchData does not contain the specified country value + + // Test contains filter with empty dictionary (edge case) + let emptyDictContainsFilter = TestFilter.contains(.searchData, [:]) + #expect(emptyDictContainsFilter.matches(TestUser(searchData: [:]))) // [:] contains [:] + #expect(!emptyDictContainsFilter.matches(TestUser(searchData: [ + "field": .string("value") + ]))) // ["field": "value"] does not contain [:] + + // Test contains filter with non-array, non-dictionary values (should return false) + let stringContainsFilter = TestFilter.contains(.name, "test") + #expect(!stringContainsFilter.matches(TestUser(name: "test"))) // String values don't support contains + + let intContainsFilter = TestFilter.contains(.age, 25) + #expect(!intContainsFilter.matches(TestUser(age: 25))) // Int values don't support contains + } + + @Test func filterMatchingPathExists() { + // Test pathExists filter with simple nested path + let simplePathFilter = TestFilter.pathExists(.searchData, "address") + #expect(simplePathFilter.matches(TestUser(searchData: [ + "address": .dictionary([ + "country": .string("NL"), + "city": .string("Amsterdam") + ]) + ]))) // searchData contains "address" field + + #expect(simplePathFilter.matches(TestUser(searchData: [ + "address": .dictionary([:]), + "other_field": .string("value") + ]))) // searchData contains "address" field even if empty + + #expect(!simplePathFilter.matches(TestUser(searchData: [ + "other_field": .string("value") + ]))) // searchData does not contain "address" field + + // Test pathExists filter with deep nested path + let deepPathFilter = TestFilter.pathExists(.searchData, "address.country") + #expect(deepPathFilter.matches(TestUser(searchData: [ + "address": .dictionary([ + "country": .string("NL"), + "city": .string("Amsterdam") + ]) + ]))) // searchData contains "address.country" path + + #expect(deepPathFilter.matches(TestUser(searchData: [ + "address": .dictionary([ + "country": .string("US"), + "state": .string("California") + ]) + ]))) // searchData contains "address.country" path with different value + + #expect(!deepPathFilter.matches(TestUser(searchData: [ + "address": .dictionary([ + "city": .string("Amsterdam") + ]) + ]))) // searchData does not contain "address.country" path + + #expect(!deepPathFilter.matches(TestUser(searchData: [ + "other_field": .string("value") + ]))) // searchData does not contain "address" field at all + + // Test pathExists filter with very deep nested path + let veryDeepPathFilter = TestFilter.pathExists(.searchData, "address.country.city.district") + #expect(veryDeepPathFilter.matches(TestUser(searchData: [ + "address": .dictionary([ + "country": .dictionary([ + "city": .dictionary([ + "district": .string("Centrum") + ]) + ]) + ]) + ]))) // searchData contains "address.country.city.district" path + + #expect(!veryDeepPathFilter.matches(TestUser(searchData: [ + "address": .dictionary([ + "country": .dictionary([ + "city": .dictionary([ + "neighborhood": .string("Centrum") + ]) + ]) + ]) + ]))) // searchData does not contain "address.country.city.district" path + + // Test pathExists filter with empty path (edge case) + let emptyPathFilter = TestFilter.pathExists(.searchData, "") + #expect(!emptyPathFilter.matches(TestUser(searchData: [ + "field": .string("value") + ]))) // Empty path should not match anything + + // Test pathExists filter with single dot path (edge case) + let singleDotPathFilter = TestFilter.pathExists(.searchData, ".") + #expect(!singleDotPathFilter.matches(TestUser(searchData: [ + "field": .string("value") + ]))) // Single dot path should not match anything + + // Test pathExists filter with non-dictionary root (should fail gracefully) + let nonDictRootFilter = TestFilter.pathExists(.name, "subfield") + #expect(!nonDictRootFilter.matches(TestUser(name: "John"))) // name is a string, not a dictionary + + // Test diacritic string comparison in path + let diacriticPathFilter = TestFilter.pathExists(.searchData, "user.nom") + #expect(diacriticPathFilter.matches(TestUser(searchData: [ + "user": .dictionary([ + "nom": .string("José") + ]) + ]))) // searchData contains "user.nom" path with diacritic value + + #expect(!diacriticPathFilter.matches(TestUser(searchData: [ + "user": .dictionary([ + "name": .string("Jose") + ]) + ]))) // searchData does not contain "user.nom" path (different field name) + } + + @Test func filterMatchingAnd() { + // Test AND filter with multiple conditions that all match + let ageFilter = TestFilter.greater(.age, 18) + let nameFilter = TestFilter.equal(.name, "John") + let activeFilter = TestFilter.equal(.isActive, true) + + let andFilter = TestFilter.and([ageFilter, nameFilter, activeFilter]) + #expect(andFilter.matches(TestUser(name: "John", age: 25, isActive: true))) // All conditions match + + // Test AND filter with one condition that doesn't match + #expect(!andFilter.matches(TestUser(name: "John", age: 17, isActive: true))) // Age condition fails + #expect(!andFilter.matches(TestUser(name: "Jane", age: 25, isActive: true))) // Name condition fails + #expect(!andFilter.matches(TestUser(name: "John", age: 25, isActive: false))) // Active condition fails + + // Test AND filter with multiple conditions that don't match + #expect(!andFilter.matches(TestUser(name: "Jane", age: 17, isActive: false))) // All conditions fail + + // Test nested AND filters + let nestedAgeFilter = TestFilter.greater(.age, 20) + let nestedHeightFilter = TestFilter.greater(.height, 170.0) + let nestedAndFilter = TestFilter.and([nestedAgeFilter, nestedHeightFilter]) + let combinedAndFilter = TestFilter.and([andFilter, nestedAndFilter]) + + #expect(combinedAndFilter.matches(TestUser(name: "John", age: 25, height: 180.0, isActive: true))) // All conditions match + #expect(!combinedAndFilter.matches(TestUser(name: "John", age: 25, height: 160.0, isActive: true))) // Height condition fails + } + + @Test func filterMatchingOr() { + // Test OR filter with multiple conditions where one matches + let nameFilter1 = TestFilter.equal(.name, "John") + let nameFilter2 = TestFilter.equal(.name, "Jane") + let ageFilter = TestFilter.greater(.age, 30) + + let orFilter = TestFilter.or([nameFilter1, nameFilter2, ageFilter]) + #expect(orFilter.matches(TestUser(name: "John"))) // First condition matches + #expect(orFilter.matches(TestUser(name: "Jane"))) // Second condition matches + #expect(orFilter.matches(TestUser(age: 35))) // Third condition matches + #expect(orFilter.matches(TestUser(name: "John", age: 35))) // Multiple conditions match + + // Test OR filter with no conditions that match + #expect(!orFilter.matches(TestUser(name: "Bob", age: 25))) // No conditions match + + // Test nested OR filters + let nestedNameFilter = TestFilter.equal(.name, "Bob") + let nestedOrFilter = TestFilter.or([nameFilter1, nestedNameFilter]) + let combinedOrFilter = TestFilter.or([orFilter, nestedOrFilter]) + + #expect(combinedOrFilter.matches(TestUser(name: "John"))) // Matches through first OR + #expect(combinedOrFilter.matches(TestUser(name: "Bob"))) // Matches through second OR + #expect(!combinedOrFilter.matches(TestUser(name: "Alice", age: 25))) // No conditions match + } + + @Test func filterMatchingTypeMismatches() { + // Test type mismatches in comparison operators - should return false + + // String vs Number comparisons + let stringVsNumberFilter = TestFilter.greater(.name, 25) + #expect(!stringVsNumberFilter.matches(TestUser(name: "John"))) // String vs Number should not match + + let numberVsStringFilter = TestFilter.greater(.age, "25") + #expect(!numberVsStringFilter.matches(TestUser(age: 30))) // Number vs String should not match + + // Test lexicographic string comparison edge cases + let stringComparisonFilter = TestFilter.greater(.name, "John") + #expect(!stringComparisonFilter.matches(TestUser(name: "John"))) // Equal strings should not be greater + #expect(stringComparisonFilter.matches(TestUser(name: "john"))) // Lowercase > uppercase lexicographically + #expect(!stringComparisonFilter.matches(TestUser(name: "JOHN"))) // Uppercase < lowercase lexicographically + + // Test number comparison edge cases + let numberComparisonFilter = TestFilter.greater(.age, 25) + #expect(!numberComparisonFilter.matches(TestUser(age: 25))) // Equal numbers should not be greater + #expect(numberComparisonFilter.matches(TestUser(age: 26))) // 26 > 25 + #expect(!numberComparisonFilter.matches(TestUser(age: 24))) // 24 < 25 + + // Test double comparison edge cases + let doubleComparisonFilter = TestFilter.greater(.height, 175.0) + #expect(!doubleComparisonFilter.matches(TestUser(height: 175.0))) // Equal doubles should not be greater + #expect(doubleComparisonFilter.matches(TestUser(height: 175.1))) // 175.1 > 175.0 + #expect(!doubleComparisonFilter.matches(TestUser(height: 174.9))) // 174.9 < 175.0 + } + + @Test func filterMatchingEdgeCases() { + // Test empty string comparisons + let emptyStringFilter = TestFilter.equal(.name, "") + #expect(emptyStringFilter.matches(TestUser(name: ""))) // Empty string should match empty string + #expect(!emptyStringFilter.matches(TestUser(name: "John"))) // Non-empty should not match empty + + // Test zero value comparisons + let zeroAgeFilter = TestFilter.equal(.age, 0) + #expect(zeroAgeFilter.matches(TestUser(age: 0))) // Zero should match zero + #expect(!zeroAgeFilter.matches(TestUser(age: 1))) // Non-zero should not match zero + + let zeroHeightFilter = TestFilter.equal(.height, 0.0) + #expect(zeroHeightFilter.matches(TestUser(height: 0.0))) // Zero double should match zero double + #expect(!zeroHeightFilter.matches(TestUser(height: 0.1))) // Non-zero should not match zero + + // Test boolean comparisons + let trueFilter = TestFilter.equal(.isActive, true) + #expect(trueFilter.matches(TestUser(isActive: true))) // True should match true + #expect(!trueFilter.matches(TestUser(isActive: false))) // False should not match true + + let falseFilter = TestFilter.equal(.isActive, false) + #expect(falseFilter.matches(TestUser(isActive: false))) // False should match false + #expect(!falseFilter.matches(TestUser(isActive: true))) // True should not match false + + // Test URL edge cases + let urlFilter = TestFilter.equal(.homepage, URL(string: "https://example.com")!) + #expect(urlFilter.matches(TestUser(homepage: URL(string: "https://example.com")!))) // Same URL should match + #expect(!urlFilter.matches(TestUser(homepage: URL(string: "https://different.com")!))) // Different URL should not match + + // Test nil URL (optional) + let nilURLFilter = TestFilter.equal(.homepage, URL(string: "https://example.com")!) + #expect(!nilURLFilter.matches(TestUser(homepage: nil))) // Nil URL should not match non-nil URL + } + + @Test func filterMatchingAutocompleteEdgeCases() { + // Test empty query (should not match anything) + let emptyQueryFilter = TestFilter.autocomplete(.name, "") + #expect(!emptyQueryFilter.matches(TestUser(name: "John"))) // Empty query should not match + #expect(!emptyQueryFilter.matches(TestUser(name: ""))) // Empty query should not match empty string + + // Test single character matching + let singleCharFilter = TestFilter.autocomplete(.name, "a") + #expect(singleCharFilter.matches(TestUser(name: "Alice"))) // Should match "Alice" + #expect(singleCharFilter.matches(TestUser(name: "a"))) // Should match single "a" + #expect(!singleCharFilter.matches(TestUser(name: "Bob"))) // Should not match "Bob" + + // Test punctuation in word boundaries + let punctuationFilter = TestFilter.autocomplete(.name, "o'connor") + #expect(punctuationFilter.matches(TestUser(name: "O'Connor"))) // Should match with apostrophe + #expect(!punctuationFilter.matches(TestUser(name: "OConnor"))) // Should not match without apostrophe + + // Test non-string values (should return false) + let nonStringFilter = TestFilter.autocomplete(.age, "25") + #expect(!nonStringFilter.matches(TestUser(age: 25))) // Non-string values should not match autocomplete + } + + @Test func filterMatchingQueryEdgeCases() { + // Test empty query (should not match anything) + let emptyQueryFilter = TestFilter.query(.name, "") + #expect(!emptyQueryFilter.matches(TestUser(name: "John"))) // Empty query should not match + #expect(!emptyQueryFilter.matches(TestUser(name: ""))) // Empty query should not match empty string + + // Test non-string values (should return false) + let nonStringFilter = TestFilter.query(.age, "25") + #expect(!nonStringFilter.matches(TestUser(age: 25))) // Non-string values should not match query + + // Test exact match + let exactMatchFilter = TestFilter.query(.name, "John") + #expect(exactMatchFilter.matches(TestUser(name: "John"))) // Exact match should work + #expect(exactMatchFilter.matches(TestUser(name: "JOHN"))) // Case-insensitive exact match should work + #expect(exactMatchFilter.matches(TestUser(name: "john"))) // Case-insensitive exact match should work + } +} From f6991308b0c7b1288b2fb1b68bf843d695ec8ca7 Mon Sep 17 00:00:00 2001 From: Alexey Alter-Pesotskiy Date: Thu, 18 Sep 2025 10:58:03 +0100 Subject: [PATCH 06/11] [CI] Get rid of mint package manager (#5) --- .github/actions/bootstrap/action.yml | 7 ---- .github/actions/xcode-cache/action.yml | 2 -- .github/workflows/sonar.yml | 2 +- Githubfile | 3 ++ Mintfile | 3 -- Scripts/bootstrap.sh | 44 +++++++++++++++++++------- fastlane/Fastfile | 6 ++-- lefthook.yml | 12 +++---- 8 files changed, 46 insertions(+), 33 deletions(-) delete mode 100644 Mintfile diff --git a/.github/actions/bootstrap/action.yml b/.github/actions/bootstrap/action.yml index 305195a..55a2001 100644 --- a/.github/actions/bootstrap/action.yml +++ b/.github/actions/bootstrap/action.yml @@ -5,13 +5,6 @@ runs: steps: - run: echo "IMAGE=${ImageOS}" >> $GITHUB_ENV shell: bash - - name: Cache Mint - uses: actions/cache@v4 - id: mint-cache - with: - path: ~/.mint - key: ${{ env.IMAGE }}-mint-${{ hashFiles('**/Mintfile') }} - restore-keys: ${{ env.IMAGE }}-mint- - uses: ./.github/actions/ruby-cache - uses: ./.github/actions/xcode-cache - run: ./Scripts/bootstrap.sh diff --git a/.github/actions/xcode-cache/action.yml b/.github/actions/xcode-cache/action.yml index 92e9488..ce9b997 100644 --- a/.github/actions/xcode-cache/action.yml +++ b/.github/actions/xcode-cache/action.yml @@ -5,8 +5,6 @@ runs: steps: - run: echo "IMAGE=${ImageOS}-${ImageVersion}" >> $GITHUB_ENV shell: bash - - run: echo "$HOME/.mint/bin" >> $GITHUB_PATH - shell: bash - name: Cache SPM uses: actions/cache@v4 id: spm-cache diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 73d5ac2..0259101 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -21,7 +21,7 @@ jobs: - uses: ./.github/actions/bootstrap env: INSTALL_SONAR: true - SKIP_MINT_BOOTSTRAP: true + SKIP_SWIFT_BOOTSTRAP: true - uses: actions/github-script@v6 id: get_pr_number diff --git a/Githubfile b/Githubfile index 2baa2a4..dd3a7bc 100644 --- a/Githubfile +++ b/Githubfile @@ -4,3 +4,6 @@ export YEETD_VERSION='1.0' export MINT_VERSION='0.17.5' export SONAR_VERSION='7.2.0.5079' export IPSW_VERSION='3.1.592' +export SWIFT_LINT_VERSION='0.55.1' +export SWIFT_FORMAT_VERSION='0.56.4' +export SWIFT_GEN_VERSION='6.5.1' diff --git a/Mintfile b/Mintfile deleted file mode 100644 index f2f6e19..0000000 --- a/Mintfile +++ /dev/null @@ -1,3 +0,0 @@ -nicklockwood/SwiftFormat@0.56.4 -SwiftGen/SwiftGen@6.6.3 -realm/SwiftLint@0.59.1 diff --git a/Scripts/bootstrap.sh b/Scripts/bootstrap.sh index b488153..6600c72 100755 --- a/Scripts/bootstrap.sh +++ b/Scripts/bootstrap.sh @@ -2,7 +2,7 @@ # shellcheck source=/dev/null # Usage: ./bootstrap.sh # This script will: -# - install Mint and bootstrap its dependencies +# - install SwiftLint, SwiftFormat, SwiftGen # - link git hooks # - install sonar-scanner if `INSTALL_SONAR` environment variable is provided # If you get `zsh: permission denied: ./bootstrap.sh` error, please run `chmod +x bootstrap.sh` first @@ -25,19 +25,41 @@ if [ "${GITHUB_ACTIONS:-}" != "true" ]; then bundle exec lefthook install fi -if [ "${SKIP_MINT_BOOTSTRAP:-}" != true ]; then - puts "Bootstrap Mint dependencies" - git clone https://github.com/yonaskolb/Mint.git fastlane/mint - root=$(pwd) - cd fastlane/mint - swift run mint install "yonaskolb/mint@${MINT_VERSION}" - cd $root - rm -rf fastlane/mint - mint bootstrap --link +if [ "${SKIP_SWIFT_BOOTSTRAP:-}" != true ]; then + puts "Install SwiftLint v${SWIFT_LINT_VERSION}" + DOWNLOAD_URL="https://github.com/realm/SwiftLint/releases/download/${SWIFT_LINT_VERSION}/SwiftLint.pkg" + DOWNLOAD_PATH="/tmp/SwiftLint-${SWIFT_LINT_VERSION}.pkg" + curl -sL "$DOWNLOAD_URL" -o "$DOWNLOAD_PATH" + sudo installer -pkg "$DOWNLOAD_PATH" -target / + swiftlint version + + puts "Install SwiftFormat v${SWIFT_FORMAT_VERSION}" + DOWNLOAD_URL="https://github.com/nicklockwood/SwiftFormat/releases/download/${SWIFT_FORMAT_VERSION}/swiftformat.zip" + DOWNLOAD_PATH="/tmp/swiftformat-${SWIFT_FORMAT_VERSION}.zip" + BIN_PATH="/usr/local/bin/swiftformat" + brew uninstall swiftformat || true + curl -sL "$DOWNLOAD_URL" -o "$DOWNLOAD_PATH" + unzip -o "$DOWNLOAD_PATH" -d /tmp/swiftformat-${SWIFT_FORMAT_VERSION} + sudo mv /tmp/swiftformat-${SWIFT_FORMAT_VERSION}/swiftformat "$BIN_PATH" + sudo chmod +x "$BIN_PATH" + swiftformat --version + + puts "Install SwiftGen v${SWIFT_GEN_VERSION}" + DOWNLOAD_URL="https://github.com/SwiftGen/SwiftGen/releases/download/${SWIFT_GEN_VERSION}/swiftgen-${SWIFT_GEN_VERSION}.zip" + DOWNLOAD_PATH="/tmp/swiftgen-${SWIFT_GEN_VERSION}.zip" + INSTALL_DIR="/usr/local/lib/swiftgen" + BIN_PATH="/usr/local/bin/swiftgen" + curl -sL "$DOWNLOAD_URL" -o "$DOWNLOAD_PATH" + sudo rm -rf "$INSTALL_DIR" + sudo mkdir -p "$INSTALL_DIR" + sudo unzip -o "$DOWNLOAD_PATH" -d "$INSTALL_DIR" + sudo sudo rm -f "$BIN_PATH" + sudo sudo ln -s "$INSTALL_DIR/bin/swiftgen" "$BIN_PATH" + swiftgen --version fi if [[ ${INSTALL_SONAR-default} == true ]]; then - puts "Install sonar scanner" + puts "Install sonar scanner v${SONAR_VERSION}" DOWNLOAD_URL="https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-${SONAR_VERSION}-macosx-x64.zip" curl -sL "${DOWNLOAD_URL}" -o ./fastlane/sonar.zip cd fastlane diff --git a/fastlane/Fastfile b/fastlane/Fastfile index ece490a..22317b4 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -136,9 +136,9 @@ lane :run_swift_format do |options| Dir.chdir('..') do strict = options[:strict] ? '--lint' : nil sources_matrix[:swiftformat].each do |path| - sh("mint run swiftformat #{strict} --config .swiftformat #{path}") - sh("mint run swiftlint lint --config .swiftlint.yml --fix --progress --reporter json #{path}") unless strict - sh("mint run swiftlint lint --config .swiftlint.yml --strict --progress --reporter json #{path}") + sh("swiftformat #{strict} --config .swiftformat #{path}") + sh("swiftlint lint --config .swiftlint.yml --fix --progress --reporter json #{path}") unless strict + sh("swiftlint lint --config .swiftlint.yml --strict --progress --reporter json #{path}") end end end diff --git a/lefthook.yml b/lefthook.yml index 8c67f61..54f6d25 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -1,26 +1,26 @@ pre-commit: parallel: false jobs: - - run: mint run swiftformat --config .swiftformat {staged_files} + - run: swiftlint lint --config .swiftlint.yml --fix --progress --reporter json {staged_files} glob: "*.{swift}" stage_fixed: true + exclude: + - "**/generated/**" + - "**/Generated/**" skip: - merge - rebase - - run: mint run swiftlint lint --config .swiftlint.yml --fix --progress --reporter json {staged_files} + - run: swiftformat --config .swiftformat {staged_files} glob: "*.{swift}" stage_fixed: true - exclude: - - "**/generated/**" - - "**/Generated/**" skip: - merge - rebase pre-push: jobs: - - run: mint run swiftlint lint --config .swiftlint.yml --strict --progress --reporter json {push_files} + - run: swiftlint lint --config .swiftlint.yml --strict --progress --reporter json {push_files} glob: "*.{swift}" exclude: - "**/generated/**" From 99aa801225387f33f71f5720ba65e4d9b6899ab5 Mon Sep 17 00:00:00 2001 From: Alexey Alter-Pesotskiy Date: Fri, 19 Sep 2025 14:36:48 +0100 Subject: [PATCH 07/11] Skip changelog on release --- Gemfile.lock | 4 ++-- Githubfile | 1 - fastlane/Fastfile | 6 ++++-- fastlane/Pluginfile | 2 +- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index fdac3fa..7592744 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -164,7 +164,7 @@ GEM bundler fastlane pry - fastlane-plugin-stream_actions (0.3.90) + fastlane-plugin-stream_actions (0.3.91) xctest_list (= 1.2.1) fastlane-plugin-versioning (0.7.1) fastlane-sirp (1.0.0) @@ -348,7 +348,7 @@ DEPENDENCIES danger-commit_lint fastlane fastlane-plugin-lizard - fastlane-plugin-stream_actions (= 0.3.90) + fastlane-plugin-stream_actions (= 0.3.91) fastlane-plugin-versioning json lefthook diff --git a/Githubfile b/Githubfile index dd3a7bc..1ff456a 100644 --- a/Githubfile +++ b/Githubfile @@ -1,7 +1,6 @@ #!/bin/bash export YEETD_VERSION='1.0' -export MINT_VERSION='0.17.5' export SONAR_VERSION='7.2.0.5079' export IPSW_VERSION='3.1.592' export SWIFT_LINT_VERSION='0.55.1' diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 22317b4..0df7162 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -40,7 +40,8 @@ lane :release do |options| sdk_names: sdk_names, github_repo: github_repo, extra_changes: extra_changes, - create_pull_request: true + create_pull_request: true, + use_changelog: false ) end @@ -69,7 +70,8 @@ lane :publish_release do |options| publish_ios_sdk( skip_git_status_check: false, version: release_version, - github_repo: github_repo + github_repo: github_repo, + use_changelog: false ) end diff --git a/fastlane/Pluginfile b/fastlane/Pluginfile index 60e26e7..f8a40ca 100644 --- a/fastlane/Pluginfile +++ b/fastlane/Pluginfile @@ -3,4 +3,4 @@ # Ensure this file is checked in to source control! gem 'fastlane-plugin-versioning' -gem 'fastlane-plugin-stream_actions', '0.3.90' +gem 'fastlane-plugin-stream_actions', '0.3.91' From eb4e3c8d7e247368672d0541bf92c62fa350045d Mon Sep 17 00:00:00 2001 From: Alexey Alter-Pesotskiy Date: Fri, 19 Sep 2025 14:47:21 +0100 Subject: [PATCH 08/11] Add info plist files --- Sources/StreamCore/Info.plist | 22 ++++++++++++++++++++++ Sources/StreamCoreUI/Info.plist | 22 ++++++++++++++++++++++ StreamCore.xcodeproj/project.pbxproj | 12 ++++++++---- 3 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 Sources/StreamCore/Info.plist create mode 100644 Sources/StreamCoreUI/Info.plist diff --git a/Sources/StreamCore/Info.plist b/Sources/StreamCore/Info.plist new file mode 100644 index 0000000..d1913fc --- /dev/null +++ b/Sources/StreamCore/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 0.0.1 + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + + diff --git a/Sources/StreamCoreUI/Info.plist b/Sources/StreamCoreUI/Info.plist new file mode 100644 index 0000000..d1913fc --- /dev/null +++ b/Sources/StreamCoreUI/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 0.0.1 + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + + diff --git a/StreamCore.xcodeproj/project.pbxproj b/StreamCore.xcodeproj/project.pbxproj index c2a7f66..5d59c9b 100644 --- a/StreamCore.xcodeproj/project.pbxproj +++ b/StreamCore.xcodeproj/project.pbxproj @@ -414,7 +414,8 @@ DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; ENABLE_MODULE_VERIFIER = YES; - GENERATE_INFOPLIST_FILE = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = "$(SRCROOT)/Sources/StreamCoreUI/Info.plist"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 15.5; @@ -447,7 +448,8 @@ DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; ENABLE_MODULE_VERIFIER = YES; - GENERATE_INFOPLIST_FILE = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = "$(SRCROOT)/Sources/StreamCoreUI/Info.plist"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 15.5; @@ -513,7 +515,8 @@ DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; ENABLE_MODULE_VERIFIER = YES; - GENERATE_INFOPLIST_FILE = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = "$(SRCROOT)/Sources/StreamCore/Info.plist"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 13.0; @@ -546,7 +549,8 @@ DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; ENABLE_MODULE_VERIFIER = YES; - GENERATE_INFOPLIST_FILE = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = "$(SRCROOT)/Sources/StreamCore/Info.plist"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 13.0; From fd5a20bac648089d582a5201f1a8322f97dfaed2 Mon Sep 17 00:00:00 2001 From: Alexey Alter-Pesotskiy Date: Fri, 19 Sep 2025 14:53:13 +0100 Subject: [PATCH 09/11] Resolve info plist files issues --- StreamCore.xcodeproj/project.pbxproj | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/StreamCore.xcodeproj/project.pbxproj b/StreamCore.xcodeproj/project.pbxproj index 5d59c9b..ad56190 100644 --- a/StreamCore.xcodeproj/project.pbxproj +++ b/StreamCore.xcodeproj/project.pbxproj @@ -476,7 +476,8 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = "$(SRCROOT)/Sources/StreamCore/Info.plist"; IPHONEOS_DEPLOYMENT_TARGET = 15.5; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = io.getstream.StreamCoreUITests; @@ -493,7 +494,8 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = "$(SRCROOT)/Sources/StreamCore/Info.plist"; IPHONEOS_DEPLOYMENT_TARGET = 15.5; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = io.getstream.StreamCoreUITests; @@ -705,7 +707,8 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = "$(SRCROOT)/Sources/StreamCore/Info.plist"; IPHONEOS_DEPLOYMENT_TARGET = 15.5; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = io.getstream.StreamCoreTests; @@ -720,7 +723,8 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = "$(SRCROOT)/Sources/StreamCore/Info.plist"; IPHONEOS_DEPLOYMENT_TARGET = 15.5; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = io.getstream.StreamCoreTests; From e083517fe9f44eb7416311d6b1b7329af5d55d10 Mon Sep 17 00:00:00 2001 From: Alexey Alter-Pesotskiy Date: Fri, 19 Sep 2025 15:09:54 +0100 Subject: [PATCH 10/11] Make membership exceptions for info plist files --- StreamCore.xcodeproj/project.pbxproj | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/StreamCore.xcodeproj/project.pbxproj b/StreamCore.xcodeproj/project.pbxproj index ad56190..01a7a14 100644 --- a/StreamCore.xcodeproj/project.pbxproj +++ b/StreamCore.xcodeproj/project.pbxproj @@ -52,6 +52,9 @@ }; 8415DA112E462E9F00FEE25F /* Exceptions for "StreamCoreUI" folder in "StreamCoreUI" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); publicHeaders = ( StreamCoreUI.h, ); @@ -66,6 +69,9 @@ }; 845495302DBA3A1300211413 /* Exceptions for "StreamCore" folder in "StreamCore" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); publicHeaders = ( StreamCore.h, ); From 02e563dd6c5085d9180ee3aa59d9e4796d6ed71a Mon Sep 17 00:00:00 2001 From: Stream Bot Date: Fri, 19 Sep 2025 14:16:18 +0000 Subject: [PATCH 11/11] Bump 0.1.0 --- Sources/StreamCore/Info.plist | 36 +++++++++---------- .../Utils/SystemEnvironment+Version.swift | 2 +- Sources/StreamCoreUI/Info.plist | 36 +++++++++---------- 3 files changed, 37 insertions(+), 37 deletions(-) diff --git a/Sources/StreamCore/Info.plist b/Sources/StreamCore/Info.plist index d1913fc..6213fda 100644 --- a/Sources/StreamCore/Info.plist +++ b/Sources/StreamCore/Info.plist @@ -1,22 +1,22 @@ - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - 0.0.1 - CFBundleVersion - $(CURRENT_PROJECT_VERSION) - + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + diff --git a/Sources/StreamCore/Utils/SystemEnvironment+Version.swift b/Sources/StreamCore/Utils/SystemEnvironment+Version.swift index 8081964..5263b66 100644 --- a/Sources/StreamCore/Utils/SystemEnvironment+Version.swift +++ b/Sources/StreamCore/Utils/SystemEnvironment+Version.swift @@ -6,5 +6,5 @@ import Foundation enum SystemEnvironment { /// A Stream Core version. - public static let version: String = "0.1.0-SNAPSHOT" + public static let version: String = "0.1.0" } diff --git a/Sources/StreamCoreUI/Info.plist b/Sources/StreamCoreUI/Info.plist index d1913fc..6213fda 100644 --- a/Sources/StreamCoreUI/Info.plist +++ b/Sources/StreamCoreUI/Info.plist @@ -1,22 +1,22 @@ - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - 0.0.1 - CFBundleVersion - $(CURRENT_PROJECT_VERSION) - + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + $(CURRENT_PROJECT_VERSION) +