diff --git a/.github/actions/bootstrap/action.yml b/.github/actions/bootstrap/action.yml index 2abfa5a9..698a8bed 100644 --- a/.github/actions/bootstrap/action.yml +++ b/.github/actions/bootstrap/action.yml @@ -23,6 +23,7 @@ runs: ~/Library/Caches/Homebrew/vale* ~/Library/Caches/Homebrew/xcparse* ~/Library/Caches/Homebrew/sonar-scanner* + ~/Library/Caches/Homebrew/google-cloud-sdk* key: ${{ env.IMAGE }}-brew-${{ hashFiles('**/Brewfile.lock.json') }} restore-keys: ${{ env.IMAGE }}-brew- - uses: ./.github/actions/ruby-cache diff --git a/.github/workflows/cron-checks.yml b/.github/workflows/cron-checks.yml index d4a23af3..17dbe170 100644 --- a/.github/workflows/cron-checks.yml +++ b/.github/workflows/cron-checks.yml @@ -59,6 +59,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ALLURE_TOKEN: ${{ secrets.ALLURE_TOKEN }} XCODE_VERSION: ${{ matrix.xcode }} + IOS_SIMULATOR_DEVICE: "${{ matrix.device }} (${{ matrix.ios }})" # For the Allure report steps: - uses: actions/checkout@v4.1.1 - uses: actions/download-artifact@v3 diff --git a/.github/workflows/xcmetrics.yml b/.github/workflows/xcmetrics.yml new file mode 100644 index 00000000..31316a13 --- /dev/null +++ b/.github/workflows/xcmetrics.yml @@ -0,0 +1,61 @@ +name: Performance Benchmarks + +on: + schedule: + # Runs "At 03:00 every night" + - cron: '0 3 * * *' + + pull_request: + branches: # FIXME: delete this + - '**' + + # types: + # - opened + # - ready_for_review + + workflow_dispatch: + +env: + HOMEBREW_NO_INSTALL_CLEANUP: 1 # Disable cleanup for homebrew, we don't need it on CI + +jobs: + xcmetrics: + name: XCMetrics + runs-on: macos-14 + env: + GITHUB_TOKEN: '${{ secrets.CI_BOT_GITHUB_TOKEN }}' + steps: + - name: Install Bot SSH Key + # if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} # FIXME: delete the comment + uses: webfactory/ssh-agent@v0.7.0 + with: + ssh-private-key: ${{ secrets.BOT_SSH_PRIVATE_KEY }} + + - uses: actions/checkout@v3.1.0 + # if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} # FIXME: delete the comment + with: + fetch-depth: 0 # to fetch git tags + + - uses: ./.github/actions/bootstrap + # if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} # FIXME: delete the comment + env: + GOOGLE_APPLICATION_CREDENTIALS: ${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }} + INSTALL_GCLOUD: true + + - name: Run Performance Metrics + # if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} # FIXME: delete the comment + run: bundle exec fastlane xcmetrics + timeout-minutes: 120 + env: + GITHUB_PR_NUM: ${{ github.event.pull_request.number }} + BRANCH_NAME: ${{ github.event.pull_request.head.ref }} + MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} + APPSTORE_API_KEY: ${{ secrets.APPSTORE_API_KEY }} + + - uses: actions/upload-artifact@v3 + if: failure() + with: + name: Test Data + path: | + derived_data/Build/Products/xcodebuild_output.log + fastlane/performance/stream-chat-swiftui.json diff --git a/.gitignore b/.gitignore index 6ba68861..8bdba89e 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,11 @@ Pods/ Carthage/ !Sample/Carthage/ +# gcloud +google-cloud-sdk +gcloud.tar.gz +gcloud-service-account-key.json + # Ignore Products folder Products/ @@ -74,6 +79,7 @@ fastlane/test_output fastlane/allurectl fastlane/xcresults fastlane/recordings +fastlane/performance StreamChatCore.framework.coverage.txt StreamChatCoreTests.xctest.coverage.txt vendor/bundle/ diff --git a/CHANGELOG.md b/CHANGELOG.md index ef689ba6..f063ea60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### ✅ Added - Link detection in the text views +- Indicator when a message was edited # [4.49.0](https://github.com/GetStream/stream-chat-swiftui/releases/tag/4.49.0) _February 28, 2024_ diff --git a/Githubfile b/Githubfile index ddabd91c..15496f60 100644 --- a/Githubfile +++ b/Githubfile @@ -3,3 +3,4 @@ export ALLURECTL_VERSION='2.15.1' export XCRESULTS_VERSION='1.16.3' export YEETD_VERSION='1.0' +export GCLOUD_VERSION='464.0.0' diff --git a/Scripts/bootstrap.sh b/Scripts/bootstrap.sh index a2ede17a..1b60aca2 100755 --- a/Scripts/bootstrap.sh +++ b/Scripts/bootstrap.sh @@ -63,3 +63,14 @@ if [[ ${INSTALL_YEETD-default} == true ]]; then puts "Running yeetd daemon" yeetd & fi + +if [[ ${INSTALL_GCLOUD-default} == true ]]; then + puts "Install gcloud" + brew install --cask google-cloud-sdk + + # Editor access required: https://console.cloud.google.com/iam-admin/iam + printf "%s" "$GOOGLE_APPLICATION_CREDENTIALS" > ./fastlane/gcloud-service-account-key.json + gcloud auth activate-service-account --key-file="./fastlane/gcloud-service-account-key.json" + gcloud config set project stream-chat-swiftui + gcloud services enable toolresults.googleapis.com +fi diff --git a/Sources/StreamChatSwiftUI/Appearance.swift b/Sources/StreamChatSwiftUI/Appearance.swift index 6300172b..0d3dbed2 100644 --- a/Sources/StreamChatSwiftUI/Appearance.swift +++ b/Sources/StreamChatSwiftUI/Appearance.swift @@ -48,3 +48,5 @@ extension EnvironmentValues { } } } + +// I'M TESTING PERFORMACE BENCHMARKS diff --git a/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift b/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift index fcaa95c9..167773c7 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift @@ -350,8 +350,11 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource { } let delay = previousMessage.createdAt.timeIntervalSince(date) + let showMessageEditedLabel = utils.messageListConfig.isMessageEditedLabelEnabled + && message.textUpdatedAt != nil - if delay > utils.messageListConfig.maxTimeIntervalBetweenMessagesInGroup { + if delay > utils.messageListConfig.maxTimeIntervalBetweenMessagesInGroup + || showMessageEditedLabel { temp[message.id]?.append(firstMessageKey) var prevInfo = temp[previousMessage.id] ?? [] prevInfo.append(lastMessageKey) diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageIdBuilder.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageIdBuilder.swift index 9020b0ac..86b53751 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageIdBuilder.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageIdBuilder.swift @@ -20,6 +20,9 @@ public class DefaultMessageIdBuilder: MessageIdBuilder { if message.localState != nil { statesId = message.uploadingStatesId } + if message.textUpdatedAt != nil { + statesId = "edited" + } return message.baseId + statesId + message.reactionScoresId + message.repliesCountId + "\(message.updatedAt)" + message.pinStateId } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListConfig.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListConfig.swift index e872dc9d..2439c86a 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListConfig.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListConfig.swift @@ -28,7 +28,8 @@ public struct MessageListConfig { handleTabBarVisibility: Bool = true, messageListAlignment: MessageListAlignment = .standard, uniqueReactionsEnabled: Bool = false, - localLinkDetectionEnabled: Bool = true + localLinkDetectionEnabled: Bool = true, + isMessageEditedLabelEnabled: Bool = true ) { self.messageListType = messageListType self.typingIndicatorPlacement = typingIndicatorPlacement @@ -50,6 +51,7 @@ public struct MessageListConfig { self.messageListAlignment = messageListAlignment self.uniqueReactionsEnabled = uniqueReactionsEnabled self.localLinkDetectionEnabled = localLinkDetectionEnabled + self.isMessageEditedLabelEnabled = isMessageEditedLabelEnabled } public let messageListType: MessageListType @@ -72,6 +74,7 @@ public struct MessageListConfig { public let messageListAlignment: MessageListAlignment public let uniqueReactionsEnabled: Bool public let localLinkDetectionEnabled: Bool + public let isMessageEditedLabelEnabled: Bool } /// Contains information about the message paddings. diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListHelperViews.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListHelperViews.swift index 85e3b637..6c409894 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListHelperViews.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListHelperViews.swift @@ -64,8 +64,19 @@ struct MessageDateView: View { var message: ChatMessage + var text: String { + var text = dateFormatter.string(from: message.createdAt) + let showMessageEditedLabel = utils.messageListConfig.isMessageEditedLabelEnabled + && message.textUpdatedAt != nil + && !message.isDeleted + if showMessageEditedLabel { + text = text + " • " + L10n.Message.Cell.edited + } + return text + } + var body: some View { - Text(dateFormatter.string(from: message.createdAt)) + Text(text) .font(fonts.footnote) .foregroundColor(Color(colors.textLowEmphasis)) .animation(nil) diff --git a/Sources/StreamChatSwiftUI/Generated/L10n.swift b/Sources/StreamChatSwiftUI/Generated/L10n.swift index 4a046e63..5bffca0a 100644 --- a/Sources/StreamChatSwiftUI/Generated/L10n.swift +++ b/Sources/StreamChatSwiftUI/Generated/L10n.swift @@ -315,6 +315,8 @@ internal enum L10n { internal static var title: String { L10n.tr("Localizable", "message.bounce.title") } } internal enum Cell { + /// Edited + internal static var edited: String { L10n.tr("Localizable", "message.cell.edited") } /// Pinned by internal static var pinnedBy: String { L10n.tr("Localizable", "message.cell.pinnedBy") } /// unknown diff --git a/Sources/StreamChatSwiftUI/Resources/en.lproj/Localizable.strings b/Sources/StreamChatSwiftUI/Resources/en.lproj/Localizable.strings index 35c8d987..2ccc032a 100644 --- a/Sources/StreamChatSwiftUI/Resources/en.lproj/Localizable.strings +++ b/Sources/StreamChatSwiftUI/Resources/en.lproj/Localizable.strings @@ -47,6 +47,7 @@ "message.gallery.photos" = "Photos"; "message.cell.pinnedBy" = "Pinned by"; "message.cell.unknownPin" = "unknown"; +"message.cell.edited" = "Edited"; "message.reactions.currentUser" = "You"; "alert.actions.cancel" = "Cancel"; diff --git a/StreamChatSwiftUI.xcodeproj/project.pbxproj b/StreamChatSwiftUI.xcodeproj/project.pbxproj index a872ec24..f3bd8737 100644 --- a/StreamChatSwiftUI.xcodeproj/project.pbxproj +++ b/StreamChatSwiftUI.xcodeproj/project.pbxproj @@ -9,6 +9,8 @@ /* Begin PBXBuildFile section */ 402C54482B6AAC0100672BFB /* StreamChatSwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8465FBB52746873A00AF091E /* StreamChatSwiftUI.framework */; }; 402C54492B6AAC0100672BFB /* StreamChatSwiftUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 8465FBB52746873A00AF091E /* StreamChatSwiftUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 8204344E2B973B7F00C5B94A /* ChannelListScrollTime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8204344D2B973B7F00C5B94A /* ChannelListScrollTime.swift */; }; + 820434502B973B8A00C5B94A /* MessageListScrollTime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8204344F2B973B8A00C5B94A /* MessageListScrollTime.swift */; }; 8205B4142AD41CC700265B84 /* StreamSwiftTestHelpers in Frameworks */ = {isa = PBXBuildFile; productRef = 8205B4132AD41CC700265B84 /* StreamSwiftTestHelpers */; }; 8205B4182AD4267200265B84 /* StreamSwiftTestHelpers in Frameworks */ = {isa = PBXBuildFile; productRef = 8205B4172AD4267200265B84 /* StreamSwiftTestHelpers */; }; 820A61A029D6D78E002257FB /* QuotedReply_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 820A619F29D6D78E002257FB /* QuotedReply_Tests.swift */; }; @@ -299,7 +301,6 @@ 846608E9278C98CB00D3D7B3 /* TypingIndicatorView_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 846608E8278C98CB00D3D7B3 /* TypingIndicatorView_Tests.swift */; }; 8469592F29BB235400134EA0 /* LazyImageExtensions_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8469592E29BB235400134EA0 /* LazyImageExtensions_Tests.swift */; }; 846AD4D0284F89B10074A0DD /* StreamChatTestMockServer in Frameworks */ = {isa = PBXBuildFile; productRef = 846AD4CF284F89B10074A0DD /* StreamChatTestMockServer */; }; - 846AD4D2284F89B10074A0DD /* StreamChatTestTools in Frameworks */ = {isa = PBXBuildFile; productRef = 846AD4D1284F89B10074A0DD /* StreamChatTestTools */; }; 846AD4D4284F95710074A0DD /* CustomChannelHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 846AD4D3284F95710074A0DD /* CustomChannelHeader.swift */; }; 846B15F42817E7630017F7A1 /* ChatChannelInfoViewModel_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 846B15F32817E7630017F7A1 /* ChatChannelInfoViewModel_Tests.swift */; }; 846D6564279FF0800094B36E /* ReactionUserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 846D6563279FF0800094B36E /* ReactionUserView.swift */; }; @@ -546,6 +547,9 @@ /* Begin PBXFileReference section */ 4A65451E274BA170003C5FA8 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; + 8204344B2B97396300C5B94A /* Performance.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = Performance.xctestplan; sourceTree = ""; }; + 8204344D2B973B7F00C5B94A /* ChannelListScrollTime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelListScrollTime.swift; sourceTree = ""; }; + 8204344F2B973B8A00C5B94A /* MessageListScrollTime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageListScrollTime.swift; sourceTree = ""; }; 820A619F29D6D78E002257FB /* QuotedReply_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuotedReply_Tests.swift; sourceTree = ""; }; 825AADF3283CCDB000237498 /* ThreadPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadPage.swift; sourceTree = ""; }; 829AB4D128578ACF002DC629 /* StreamTestCase+Tags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StreamTestCase+Tags.swift"; sourceTree = ""; }; @@ -1023,7 +1027,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 846AD4D2284F89B10074A0DD /* StreamChatTestTools in Frameworks */, 8205B4182AD4267200265B84 /* StreamSwiftTestHelpers in Frameworks */, 402C54482B6AAC0100672BFB /* StreamChatSwiftUI.framework in Frameworks */, 846AD4D0284F89B10074A0DD /* StreamChatTestMockServer in Frameworks */, @@ -1071,6 +1074,15 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 8204344C2B973B6D00C5B94A /* Performance */ = { + isa = PBXGroup; + children = ( + 8204344D2B973B7F00C5B94A /* ChannelListScrollTime.swift */, + 8204344F2B973B8A00C5B94A /* MessageListScrollTime.swift */, + ); + path = Performance; + sourceTree = ""; + }; 82A1814528FD69E8005F9D43 /* Message Delivery Status */ = { isa = PBXGroup; children = ( @@ -1313,6 +1325,7 @@ isa = PBXGroup; children = ( A3828EB0283F73EE00538258 /* StreamChatSwiftUITestsApp.xctestplan */, + 8204344B2B97396300C5B94A /* Performance.xctestplan */, A3600B36283E9EC900E1C930 /* StreamChatSwiftUITests.swift */, A3600B33283E9EBA00E1C930 /* Extensions */, 8463D92428365E8F002B1894 /* Pages */, @@ -2056,14 +2069,15 @@ A3600B30283E9E4100E1C930 /* Tests */ = { isa = PBXGroup; children = ( + 8204344C2B973B6D00C5B94A /* Performance */, A3600B3F283F651200E1C930 /* Base TestCase */, + 82A1814528FD69E8005F9D43 /* Message Delivery Status */, 82A1813D28FD68A3005F9D43 /* ChannelList_Tests.swift */, A3600B31283E9E4700E1C930 /* MessageList_Tests.swift */, 829AB4D32858A532002DC629 /* Reactions_Tests.swift */, 82A1813B28F9BA53005F9D43 /* Attachments_Tests.swift */, 82A1814328FD69AE005F9D43 /* SlowMode_Tests.swift */, 829AB4D128578ACF002DC629 /* StreamTestCase+Tags.swift */, - 82A1814528FD69E8005F9D43 /* Message Delivery Status */, 82A1813F28FD691B005F9D43 /* Ephemeral_Messages_Tests.swift */, 82A1814128FD694A005F9D43 /* PushNotification_Tests.swift */, 820A619F29D6D78E002257FB /* QuotedReply_Tests.swift */, @@ -2118,7 +2132,6 @@ name = StreamChatSwiftUITestsAppTests; packageProductDependencies = ( 846AD4CF284F89B10074A0DD /* StreamChatTestMockServer */, - 846AD4D1284F89B10074A0DD /* StreamChatTestTools */, 8205B4172AD4267200265B84 /* StreamSwiftTestHelpers */, ); productName = StreamChatSwiftUITestsAppTests; @@ -2355,6 +2368,8 @@ 825AADF4283CCDB000237498 /* ThreadPage.swift in Sources */, A3828EAD283F6CFE00538258 /* StartPage.swift in Sources */, A3600B41283F652400E1C930 /* StreamTestCase.swift in Sources */, + 8204344E2B973B7F00C5B94A /* ChannelListScrollTime.swift in Sources */, + 820434502B973B8A00C5B94A /* MessageListScrollTime.swift in Sources */, 82A1814928FD6A0C005F9D43 /* MessageDeliveryStatus+ChannelList_Tests.swift in Sources */, 82A1814228FD694A005F9D43 /* PushNotification_Tests.swift in Sources */, 82A1814428FD69AE005F9D43 /* SlowMode_Tests.swift in Sources */, @@ -2873,15 +2888,23 @@ 8400A359282E6BE30067D3A0 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - CODE_SIGN_STYLE = Automatic; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = EHV7XZLAHA; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = EHV7XZLAHA; GCC_OPTIMIZATION_LEVEL = s; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = io.getstream.iOS.StreamChatSwiftUITestsAppTests; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match Development io.getstream.iOS.StreamChatSwiftUITestsAppTests.xctrunner"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 5.0; @@ -2893,14 +2916,22 @@ 8400A35A282E6BE30067D3A0 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - CODE_SIGN_STYLE = Automatic; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = EHV7XZLAHA; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = EHV7XZLAHA; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = io.getstream.iOS.StreamChatSwiftUITestsAppTests; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match Development io.getstream.iOS.StreamChatSwiftUITestsAppTests.xctrunner"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -2914,10 +2945,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = StreamChatSwiftUITestsApp/StreamChatSwiftUITestsApp.entitlements; - CODE_SIGN_STYLE = Automatic; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"StreamChatSwiftUITestsApp/Preview Content\""; - DEVELOPMENT_TEAM = EHV7XZLAHA; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = EHV7XZLAHA; ENABLE_PREVIEWS = YES; GCC_OPTIMIZATION_LEVEL = s; GENERATE_INFOPLIST_FILE = YES; @@ -2938,6 +2971,12 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = io.getstream.iOS.StreamChatSwiftUITestsApp; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match Development io.getstream.iOS.StreamChatSwiftUITestsApp"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 5.0; @@ -2951,10 +2990,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = StreamChatSwiftUITestsApp/StreamChatSwiftUITestsApp.entitlements; - CODE_SIGN_STYLE = Automatic; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"StreamChatSwiftUITestsApp/Preview Content\""; - DEVELOPMENT_TEAM = EHV7XZLAHA; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = EHV7XZLAHA; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = StreamChatSwiftUITestsApp/Info.plist; @@ -2974,6 +3015,12 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = io.getstream.iOS.StreamChatSwiftUITestsApp; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match Development io.getstream.iOS.StreamChatSwiftUITestsApp"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -3375,8 +3422,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/GetStream/stream-chat-swift.git"; requirement = { - kind = upToNextMajorVersion; - minimumVersion = 4.49.0; + branch = develop; + kind = branch; }; }; E3A1C01A282BAC66002D1E26 /* XCRemoteSwiftPackageReference "sentry-cocoa" */ = { @@ -3420,11 +3467,6 @@ package = 84E95A75284A486600699FD3 /* XCRemoteSwiftPackageReference "stream-chat-swift" */; productName = StreamChatTestMockServer; }; - 846AD4D1284F89B10074A0DD /* StreamChatTestTools */ = { - isa = XCSwiftPackageProductDependency; - package = 84E95A75284A486600699FD3 /* XCRemoteSwiftPackageReference "stream-chat-swift" */; - productName = StreamChatTestTools; - }; 84B87F222861C0C900959CBE /* StreamChat */ = { isa = XCSwiftPackageProductDependency; package = 84E95A75284A486600699FD3 /* XCRemoteSwiftPackageReference "stream-chat-swift" */; diff --git a/StreamChatSwiftUI.xcodeproj/xcshareddata/xcschemes/StreamChatSwiftUITestsApp.xcscheme b/StreamChatSwiftUI.xcodeproj/xcshareddata/xcschemes/StreamChatSwiftUITestsApp.xcscheme index 11283903..df23aea3 100644 --- a/StreamChatSwiftUI.xcodeproj/xcshareddata/xcschemes/StreamChatSwiftUITestsApp.xcscheme +++ b/StreamChatSwiftUI.xcodeproj/xcshareddata/xcschemes/StreamChatSwiftUITestsApp.xcscheme @@ -24,14 +24,17 @@ + + Bool { + StreamRuntimeCheck._isBackgroundMappingEnabled = true disableAnimations() registerForPushNotifications() UNUserNotificationCenter.current().delegate = NotificationsHandler.shared diff --git a/StreamChatSwiftUITestsAppTests/Performance.xctestplan b/StreamChatSwiftUITestsAppTests/Performance.xctestplan new file mode 100644 index 00000000..247c0708 --- /dev/null +++ b/StreamChatSwiftUITestsAppTests/Performance.xctestplan @@ -0,0 +1,28 @@ +{ + "configurations" : [ + { + "id" : "763B91DE-11E7-42EB-A549-B861D3268B84", + "name" : "Configuration 1", + "options" : { + + } + } + ], + "defaultOptions" : { + + }, + "testTargets" : [ + { + "selectedTests" : [ + "ChannelListScrollTime\/testChannelListScrollTime()", + "MessageListScrollTime\/testMessageListScrollTime()" + ], + "target" : { + "containerPath" : "container:StreamChatSwiftUI.xcodeproj", + "identifier" : "8400A350282E6BE30067D3A0", + "name" : "StreamChatSwiftUITestsAppTests" + } + } + ], + "version" : 1 +} diff --git a/StreamChatSwiftUITestsAppTests/Robots/UserRobot.swift b/StreamChatSwiftUITestsAppTests/Robots/UserRobot.swift index a66acfa9..82b9393d 100644 --- a/StreamChatSwiftUITestsAppTests/Robots/UserRobot.swift +++ b/StreamChatSwiftUITestsAppTests/Robots/UserRobot.swift @@ -29,11 +29,10 @@ final class UserRobot: Robot { ChannelListPage.userAvatar.safeTap() return self } - + @discardableResult - func openChannel(channelCellIndex: Int = 0) -> Self { - let minExpectedCount = channelCellIndex + 1 - let cells = ChannelListPage.cells.waitCount(minExpectedCount) + func waitForChannelListToLoad() -> Self { + let cells = ChannelListPage.cells.waitCount(1, timeout: 7) // TODO: CIS-1737 if !cells.firstMatch.exists { @@ -44,18 +43,19 @@ final class UserRobot: Robot { sleep(1) app.launch() login() - cells.waitCount(minExpectedCount) + cells.waitCount(1) if cells.firstMatch.exists { break } } } - XCTAssertGreaterThanOrEqual( - cells.count, - minExpectedCount, - "Channel cell is not found at index #\(channelCellIndex)" - ) + XCTAssertGreaterThanOrEqual(cells.count, 1, "Channel list has not been loaded") + return self + } - cells.allElementsBoundByIndex[channelCellIndex].safeTap() + @discardableResult + func openChannel(channelCellIndex: Int = 0) -> Self { + waitForChannelListToLoad() + ChannelListPage.cells.allElementsBoundByIndex[channelCellIndex].waitForHitPoint().safeTap() return self } } @@ -306,11 +306,27 @@ extension UserRobot { .tapOnBackButton() .tapOnBackButton() } + + @discardableResult + func scrollChannelListDown(times: Int = 1) -> Self { + for _ in 1...times { + ChannelListPage.list.swipeUp(velocity: .fast) + } + return self + } + + @discardableResult + func scrollChannelListUp(times: Int = 1) -> Self { + for _ in 1...times { + ChannelListPage.list.swipeDown(velocity: .fast) + } + return self + } @discardableResult func scrollMessageListDown(times: Int = 1) -> Self { for _ in 1...times { - MessageListPage.list.swipeUp() + MessageListPage.list.swipeUp(velocity: .fast) } return self } @@ -318,7 +334,7 @@ extension UserRobot { @discardableResult func scrollMessageListUp(times: Int = 1) -> Self { for _ in 1...times { - MessageListPage.list.swipeDown() + MessageListPage.list.swipeDown(velocity: .fast) } return self } diff --git a/StreamChatSwiftUITestsAppTests/StreamChatSwiftUITestsApp.xctestplan b/StreamChatSwiftUITestsAppTests/StreamChatSwiftUITestsApp.xctestplan index 5c0531e9..396ca270 100644 --- a/StreamChatSwiftUITestsAppTests/StreamChatSwiftUITestsApp.xctestplan +++ b/StreamChatSwiftUITestsAppTests/StreamChatSwiftUITestsApp.xctestplan @@ -19,6 +19,10 @@ }, "testTargets" : [ { + "skippedTests" : [ + "ChannelListScrollTime", + "MessageListScrollTime" + ], "target" : { "containerPath" : "container:StreamChatSwiftUI.xcodeproj", "identifier" : "8400A350282E6BE30067D3A0", diff --git a/StreamChatSwiftUITestsAppTests/Tests/Base TestCase/StreamTestCase.swift b/StreamChatSwiftUITestsAppTests/Tests/Base TestCase/StreamTestCase.swift index 42637dc6..896aeb59 100644 --- a/StreamChatSwiftUITestsAppTests/Tests/Base TestCase/StreamTestCase.swift +++ b/StreamChatSwiftUITestsAppTests/Tests/Base TestCase/StreamTestCase.swift @@ -15,6 +15,7 @@ class StreamTestCase: XCTestCase { var participantRobot: ParticipantRobot! var server: StreamMockServer! var recordVideo = false + var mockServerEnabled = true override func setUpWithError() throws { continueAfterFailure = false @@ -47,15 +48,17 @@ class StreamTestCase: XCTestCase { extension StreamTestCase { private func useMockServer() { - // Leverage web socket server - app.setLaunchArguments(.useMockServer) - - // Configure web socket host - app.setEnvironmentVariables([ - .websocketHost: "\(MockServerConfiguration.websocketHost)", - .httpHost: "\(MockServerConfiguration.httpHost)", - .port: "\(MockServerConfiguration.port)" - ]) + if mockServerEnabled { + // Leverage web socket server + app.setLaunchArguments(.useMockServer) + + // Configure web socket host + app.setEnvironmentVariables([ + .websocketHost: "\(MockServerConfiguration.websocketHost)", + .httpHost: "\(MockServerConfiguration.httpHost)", + .port: "\(MockServerConfiguration.port)" + ]) + } } private func attachElementTree() { diff --git a/StreamChatSwiftUITestsAppTests/Tests/Performance/ChannelListScrollTime.swift b/StreamChatSwiftUITestsAppTests/Tests/Performance/ChannelListScrollTime.swift new file mode 100644 index 00000000..974bedcd --- /dev/null +++ b/StreamChatSwiftUITestsAppTests/Tests/Performance/ChannelListScrollTime.swift @@ -0,0 +1,34 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +import XCTest + +@available(iOS 15.0, *) +class ChannelListScrollTime: StreamTestCase { + + override func setUpWithError() throws { + mockServerEnabled = false + try super.setUpWithError() + } + + func testChannelListScrollTime() { + WHEN("user opens the channel list") { + backendRobot.generateChannels(count: 100, messagesCount: 1) + userRobot + .login() + .waitForChannelListToLoad() + .scrollChannelListDown() // to load the channels + .scrollChannelListUp() + } + THEN("user scrolls the channel list") { + let measureOptions = XCTMeasureOptions() + measureOptions.invocationOptions = [.manuallyStop] + measure(metrics: [XCTOSSignpostMetric.scrollingAndDecelerationMetric], options: measureOptions) { + userRobot.scrollChannelListDown() + stopMeasuring() + userRobot.scrollChannelListUp() + } + } + } +} diff --git a/StreamChatSwiftUITestsAppTests/Tests/Performance/MessageListScrollTime.swift b/StreamChatSwiftUITestsAppTests/Tests/Performance/MessageListScrollTime.swift new file mode 100644 index 00000000..6b21df53 --- /dev/null +++ b/StreamChatSwiftUITestsAppTests/Tests/Performance/MessageListScrollTime.swift @@ -0,0 +1,26 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +import XCTest + +@available(iOS 15.0, *) +class MessageListScrollTime: StreamTestCase { + + func testMessageListScrollTime() { + WHEN("user opens the message list") { + backendRobot.generateChannels(count: 1, messagesCount: 100, withAttachments: true) + participantRobot.addReaction(type: .like) + userRobot.login().openChannel() + } + THEN("user scrolls the message list") { + let measureOptions = XCTMeasureOptions() + measureOptions.invocationOptions = [.manuallyStop] + measure(metrics: [XCTOSSignpostMetric.scrollingAndDecelerationMetric], options: measureOptions) { + userRobot.scrollMessageListUp() + stopMeasuring() + userRobot.scrollMessageListDown() + } + } + } +} diff --git a/fastlane/.rubocop.yml b/fastlane/.rubocop.yml index bf1c07c8..6937df5b 100755 --- a/fastlane/.rubocop.yml +++ b/fastlane/.rubocop.yml @@ -22,6 +22,8 @@ Performance/RegexpMatch: Enabled: false Performance/StringReplacement: Enabled: false +Performance/CollectionLiteralInLoop: + Enabled: false Style/NumericPredicate: Enabled: false Metrics/BlockLength: diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 637df6b5..3ef4eb9e 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -13,7 +13,9 @@ sdk_names = ['StreamChatSwiftUI'] github_repo = ENV['GITHUB_REPOSITORY'] || 'GetStream/stream-chat-swiftui' derived_data_path = 'derived_data' source_packages_path = 'spm_cache' +performance_path = "performance/#{github_repo.split('/').last}.json" buildcache_xcargs = 'CC=clang CPLUSPLUS=clang++ LD=clang LDPLUSPLUS=clang++' +testlab_bucket = 'gs://test-lab-w87d6jtkvp4tm-ymb61x4m8chpa' is_localhost = !is_ci @force_check = false develop_branch = 'main' @@ -134,7 +136,11 @@ desc "If `readonly: true` (by default), installs all Certs and Profiles necessar lane :match_me do |options| custom_match( api_key: appstore_api_key, - app_identifier: ['io.getstream.iOS.DemoAppSwiftUI'], + app_identifier: [ + 'io.getstream.iOS.DemoAppSwiftUI', + 'io.getstream.iOS.StreamChatSwiftUITestsApp', + 'io.getstream.iOS.StreamChatSwiftUITestsAppTests.xctrunner' + ], readonly: options[:readonly], register_device: options[:register_device] ) @@ -206,6 +212,150 @@ lane :stop_sinatra do sh('lsof -t -i:4567 | xargs kill -9') end +lane :xcmetrics do |options| + next unless is_check_required(sources: sources_matrix[:xcmetrics], force_check: @force_check) + + ['test_output/', 'performance/', "../#{derived_data_path}/Build/Products"].each { |dir| FileUtils.remove_dir(dir, force: true) } + + match_me + + scan( + project: xcode_project, + scheme: 'StreamChatSwiftUITestsApp', + testplan: 'Performance', + result_bundle: true, + derived_data_path: derived_data_path, + cloned_source_packages_path: source_packages_path, + clean: is_localhost, + xcargs: buildcache_xcargs, + sdk: 'iphoneos', + skip_detect_devices: true, + build_for_testing: true + ) + + firebase_error = '' + xcodebuild_output = '' + Dir.chdir("../#{derived_data_path}/Build/Products") do + begin + sh("zip -r MyTests.zip .") + sh("gcloud firebase test ios run --test MyTests.zip --timeout 7m --results-dir test_output --device 'model=iphone14pro,version=16.6,orientation=portrait'") + rescue StandardError => e + UI.error("Test failed on Firebase:\n#{e}") + firebase_error = e + end + + sh("gsutil cp -r #{testlab_bucket}/test_output/iphone14pro-16.6-en-portrait/xcodebuild_output.log xcodebuild_output.log") + xcodebuild_output = File.read('xcodebuild_output.log') + end + + sh("git clone git@github.com:GetStream/stream-swift-performance-benchmarks.git #{File.dirname(performance_path)}") + branch_performance = xcmetrics_log_parser(log: xcodebuild_output) + performance_benchmarks = JSON.parse(File.read(performance_path)) + expected_performance = performance_benchmarks['benchmark'] + + markdown_table = "## StreamChat XCMetrics\n| `target` | `metric` | `benchmark` | `branch` | `performance` | `status` |\n| - | - | - | - | - | - |\n" + ['testMessageListScrollTime', 'testChannelListScrollTime'].each do |test_name| + index = 0 + ['hitches_total_duration', 'duration', 'hitch_time_ratio', 'frame_rate', 'number_of_hitches'].each do |metric| + benchmark_value = expected_performance[test_name][metric]['value'] + branch_value = branch_performance[test_name][metric]['value'] + value_extension = branch_performance[test_name][metric]['ext'] + + max_stddev = 0.1 # Default Xcode Max STDDEV is 10% + status_emoji = + if branch_value > benchmark_value && branch_value < benchmark_value + (benchmark_value * max_stddev) + '🟡' # Warning if a branch is 10% less performant than the benchmark + elsif branch_value > benchmark_value + '🔴' # Failure if a branch is more than 10% less performant than the benchmark + else + '🟢' # Success if a branch is more performant or equals to the benchmark + end + + benchmark_value_avoids_zero_division = benchmark_value == 0 ? 1 : benchmark_value + diff = ((benchmark_value - branch_value) * 100.0 / benchmark_value_avoids_zero_division).round(2) + diff_emoji = diff > 0 ? '🔼' : '🔽' + + title = metric.to_s.gsub('_', ' ').capitalize + target = index.zero? ? test_name.match(/(?<=test)(.*?)(?=ScrollTime)/).to_s : '' + index += 1 + + markdown_table << "| #{target} | #{title} | #{benchmark_value} #{value_extension} | #{branch_value} #{value_extension} | #{diff}% #{diff_emoji} | #{status_emoji} |\n" + FastlaneCore::PrintTable.print_values( + title: "⏳ #{title} ⏳", + config: { + benchmark: "#{benchmark_value} #{value_extension}", + branch: "#{branch_value} #{value_extension}", + diff: "#{diff}% #{diff_emoji}", + status: status_emoji + } + ) + end + end + + UI.user_error!("See Firebase error above ☝️") unless firebase_error.to_s.empty? + + if is_ci && ENV.key?('GITHUB_PR_NUM') + pr_comment_required = true + performance_benchmarks[current_branch] = branch_performance + UI.message("Performance benchmarks: #{performance_benchmarks}") + File.write(performance_path, JSON.pretty_generate(performance_benchmarks)) + + Dir.chdir(File.dirname(performance_path)) do + if sh('git status -s', log: false).to_s.empty? + pr_comment_required = false + UI.important('No changes in performance benchmarks. Skipping commit and comment.') + else + sh('git add -A') + sh("git commit -m 'Update #{github_repo.split('/').last}.json: #{current_branch}'") + sh('git push') + end + end + + sh("gh pr comment #{ENV.fetch('GITHUB_PR_NUM')} -b '#{markdown_table}'") if pr_comment_required + end + + UI.user_error!('Performance benchmark failed.') if markdown_table.include?('🔴') +end + +private_lane :xcmetrics_log_parser do |options| + log = options[:log] + method = 'Scroll_DraggingAndDeceleration' + metrics = {} + + ['testMessageListScrollTime', 'testChannelListScrollTime'].each do |test_name| + hitches_total_duration = log.match(/#{test_name}\]' measured \[Hitches Total Duration \(#{method}\), ms\] average: (\d+\.\d+)/) + duration = log.match(/#{test_name}\]' measured \[Duration \(#{method}\), s\] average: (\d+\.\d+)/) + hitch_time_ratio = log.match(/#{test_name}\]' measured \[Hitch Time Ratio \(#{method}\), ms per s\] average: (\d+\.\d+)/) + frame_rate = log.match(/#{test_name}\]' measured \[Frame Rate \(#{method}\), fps\] average: (\d+\.\d+)/) + number_of_hitches = log.match(/#{test_name}\]' measured \[Number of Hitches \(#{method}\), hitches\] average: (\d+\.\d+)/) + + metrics[test_name] = { + 'hitches_total_duration' => { + 'value' => hitches_total_duration ? hitches_total_duration[1].to_f.round(1) : '?', + 'ext' => 'ms' + }, + 'duration' => { + 'value' => duration ? duration[1].to_f.round(2) : '?', + 'ext' => 's' + }, + 'hitch_time_ratio' => { + 'value' => hitch_time_ratio ? hitch_time_ratio[1].to_f.round(2) : '?', + 'ext' => 'ms per s' + }, + 'frame_rate' => { + 'value' => frame_rate ? frame_rate[1].to_f.round(2) : '?', + 'ext' => 'fps' + }, + 'number_of_hitches' => { + 'value' => number_of_hitches ? number_of_hitches[1].to_f.round(2) : '?', + 'ext' => '' + } + } + end + + metrics +end + desc 'Runs e2e ui tests using mock server in Debug config' lane :test_e2e_mock do |options| next unless is_check_required(sources: sources_matrix[:e2e], force_check: @force_check) @@ -390,7 +540,8 @@ lane :sources_matrix do e2e: ['Sources', 'StreamChatSwiftUITestsAppTests', 'StreamChatSwiftUITestsApp'], ui: ['Sources', 'StreamChatSwiftUITests', xcode_project], sample_apps: ['Sources', 'DemoAppSwiftUI', xcode_project], - ruby: ['fastlane', 'Gemfile', 'Gemfile.lock'] + ruby: ['fastlane', 'Gemfile', 'Gemfile.lock'], + xcmetrics: ['Sources'] } end @@ -422,7 +573,20 @@ private_lane :create_pr do |options| end private_lane :current_branch do - ENV['BRANCH_NAME'].to_s.empty? ? git_branch : ENV.fetch('BRANCH_NAME') + github_pr_branch_name = ENV['BRANCH_NAME'].to_s + github_ref_branch_name = ENV['GITHUB_REF'].to_s.sub('refs/heads/', '') + fastlane_branch_name = git_branch + + branch_name = if !github_pr_branch_name.empty? + github_pr_branch_name + elsif !fastlane_branch_name.empty? + fastlane_branch_name + elsif !github_ref_branch_name.empty? + github_ref_branch_name + end + + UI.important("Current branch: #{branch_name} 🕊️") + branch_name end private_lane :git_status do |options| diff --git a/fastlane/Scanfile b/fastlane/Scanfile index cd2beff1..932dcc5d 100644 --- a/fastlane/Scanfile +++ b/fastlane/Scanfile @@ -4,8 +4,6 @@ # In general, you can use the options available # fastlane scan --help -devices(["iPhone 12"]) - # Needed for Sonar code_coverage(true)