From a5ec12e3dfb1b8f236ca0140e97d9914c602b34b Mon Sep 17 00:00:00 2001 From: Magesh K <47920326+mahee96@users.noreply.github.com> Date: Fri, 21 Feb 2025 12:14:54 +0530 Subject: [PATCH 01/25] - UITests: Setup for UI Tests --- AltStore.xcodeproj/project.pbxproj | 154 +++++++++++++++++- .../xcshareddata/xcschemes/SideStore.xcscheme | 21 ++- SideStore/Tests/SideStoreTests.xctestplan | 33 ++++ SideStore/Tests/UITests/UITests.swift | 44 +++++ .../Tests/UITests/UITestsLaunchTests.swift | 34 ++++ xcconfigs/UITests.xcconfig | 3 + 6 files changed, 282 insertions(+), 7 deletions(-) create mode 100644 SideStore/Tests/SideStoreTests.xctestplan create mode 100644 SideStore/Tests/UITests/UITests.swift create mode 100644 SideStore/Tests/UITests/UITestsLaunchTests.swift create mode 100644 xcconfigs/UITests.xcconfig diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index 6d8fea94..49adbfa0 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -89,6 +89,9 @@ A8C6D5182D1EE95B00DF01F1 /* OpenSSL.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = A859ED5B2D1EE80D003DCC58 /* OpenSSL.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; A8D484D82D0CD306002C691D /* AltBackup.ipa in Resources */ = {isa = PBXBuildFile; fileRef = A8D484D72D0CD306002C691D /* AltBackup.ipa */; }; A8D49F532D3D2F9400844B92 /* ProcessInfo+AltStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8D49F522D3D2F9400844B92 /* ProcessInfo+AltStore.swift */; }; + A8E2DB312D684E2A009E5D31 /* UITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8E2DB2E2D684E2A009E5D31 /* UITests.swift */; }; + A8E2DB322D684E2A009E5D31 /* UITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8E2DB2F2D684E2A009E5D31 /* UITestsLaunchTests.swift */; }; + A8E2DB342D68507F009E5D31 /* SideStoreTests.xctestplan in Resources */ = {isa = PBXBuildFile; fileRef = A8E2DB332D68507F009E5D31 /* SideStoreTests.xctestplan */; }; A8EA195F2D4982D600DC6322 /* BaseEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8EA195E2D4982D600DC6322 /* BaseEntity.swift */; }; A8F838922D048E8F00ED425D /* libEmotionalDamage.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 19104DB22909C06C00C49C7B /* libEmotionalDamage.a */; }; A8F838932D048E8F00ED425D /* libminimuxer.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 191E5FAB290A5D92001A3B7C /* libminimuxer.a */; }; @@ -504,6 +507,13 @@ remoteGlobalIDString = BF58047A246A28F7008AE704; remoteInfo = AltBackup; }; + A8E2DB272D684CBD009E5D31 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = BFD247622284B9A500981D42 /* Project object */; + proxyType = 1; + remoteGlobalIDString = BFD247692284B9A500981D42; + remoteInfo = SideStore; + }; BF66EE832501AE50007EE018 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = BFD247622284B9A500981D42 /* Project object */; @@ -667,6 +677,11 @@ A8C38C372D2084D000E83DBD /* ConsoleLogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsoleLogView.swift; sourceTree = ""; }; A8D484D72D0CD306002C691D /* AltBackup.ipa */ = {isa = PBXFileReference; lastKnownFileType = file; path = AltBackup.ipa; sourceTree = ""; }; A8D49F522D3D2F9400844B92 /* ProcessInfo+AltStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProcessInfo+AltStore.swift"; sourceTree = ""; }; + A8E2DB212D684CBD009E5D31 /* UITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = UITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + A8E2DB2C2D684D39009E5D31 /* UITests.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = UITests.xcconfig; sourceTree = ""; }; + A8E2DB2E2D684E2A009E5D31 /* UITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITests.swift; sourceTree = ""; }; + A8E2DB2F2D684E2A009E5D31 /* UITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestsLaunchTests.swift; sourceTree = ""; }; + A8E2DB332D68507F009E5D31 /* SideStoreTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = SideStoreTests.xctestplan; sourceTree = ""; }; A8EA195E2D4982D600DC6322 /* BaseEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseEntity.swift; sourceTree = ""; }; A8F66C3C2D04D433009689E6 /* em_proxy.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = em_proxy.h; sourceTree = ""; }; A8F66C602D04D464009689E6 /* minimuxer.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; path = minimuxer.xcodeproj; sourceTree = ""; }; @@ -1066,6 +1081,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A8E2DB1E2D684CBD009E5D31 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; BF580478246A28F7008AE704 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -1216,6 +1238,7 @@ A85ACB902D1F31C400AA3DE7 /* AltStore.release.xcconfig */, A85ACB8E2D1F31C400AA3DE7 /* AltBackup.xcconfig */, A85ACB932D1F31C400AA3DE7 /* AltWidgetExtension.xcconfig */, + A8E2DB2C2D684D39009E5D31 /* UITests.xcconfig */, ); path = xcconfigs; sourceTree = ""; @@ -1322,6 +1345,24 @@ path = common; sourceTree = ""; }; + A8E2DB302D684E2A009E5D31 /* UITests */ = { + isa = PBXGroup; + children = ( + A8E2DB2E2D684E2A009E5D31 /* UITests.swift */, + A8E2DB2F2D684E2A009E5D31 /* UITestsLaunchTests.swift */, + ); + path = UITests; + sourceTree = ""; + }; + A8E2DB352D6850A9009E5D31 /* Tests */ = { + isa = PBXGroup; + children = ( + A8E2DB302D684E2A009E5D31 /* UITests */, + A8E2DB332D68507F009E5D31 /* SideStoreTests.xctestplan */, + ); + path = Tests; + sourceTree = ""; + }; A8EA19602D4982E300DC6322 /* DatabaseManager */ = { isa = PBXGroup; children = ( @@ -1350,6 +1391,7 @@ A8F66C072D04C025009689E6 /* SideStore */ = { isa = PBXGroup; children = ( + A8E2DB352D6850A9009E5D31 /* Tests */, A8F66C5C2D04D433009689E6 /* minimuxer */, A8F66C602D04D464009689E6 /* minimuxer.xcodeproj */, A8F66C412D04D433009689E6 /* em_proxy */, @@ -1919,6 +1961,7 @@ 19104DB22909C06C00C49C7B /* libEmotionalDamage.a */, 191E5FAB290A5D92001A3B7C /* libminimuxer.a */, D586D39828EF58B0000E101F /* AltTests.xctest */, + A8E2DB212D684CBD009E5D31 /* UITests.xctest */, ); name = Products; sourceTree = ""; @@ -2399,6 +2442,26 @@ productReference = 191E5FAB290A5D92001A3B7C /* libminimuxer.a */; productType = "com.apple.product-type.library.static"; }; + A8E2DB202D684CBD009E5D31 /* UITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = A8E2DB292D684CBD009E5D31 /* Build configuration list for PBXNativeTarget "UITests" */; + buildPhases = ( + A8E2DB1D2D684CBD009E5D31 /* Sources */, + A8E2DB1E2D684CBD009E5D31 /* Frameworks */, + A8E2DB1F2D684CBD009E5D31 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + A8E2DB282D684CBD009E5D31 /* PBXTargetDependency */, + ); + name = UITests; + packageProductDependencies = ( + ); + productName = UITests; + productReference = A8E2DB212D684CBD009E5D31 /* UITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; BF45872A2298D31600BD7491 /* libimobiledevice */ = { isa = PBXNativeTarget; buildConfigurationList = BF4587332298D31600BD7491 /* Build configuration list for PBXNativeTarget "libimobiledevice" */; @@ -2510,7 +2573,7 @@ BFD247622284B9A500981D42 /* Project object */ = { isa = PBXProject; attributes = { - LastSwiftUpdateCheck = 1400; + LastSwiftUpdateCheck = 1620; LastUpgradeCheck = 1020; ORGANIZATIONNAME = SideStore; TargetAttributes = { @@ -2520,6 +2583,10 @@ 191E5FAA290A5D92001A3B7C = { CreatedOnToolsVersion = 14.0; }; + A8E2DB202D684CBD009E5D31 = { + CreatedOnToolsVersion = 16.2; + TestTargetID = BFD247692284B9A500981D42; + }; BF45872A2298D31600BD7491 = { CreatedOnToolsVersion = 10.2.1; }; @@ -2586,6 +2653,7 @@ BF989166250AABF3002ACF50 /* AltWidgetExtension */, 19104DB12909C06C00C49C7B /* EmotionalDamage */, 191E5FAA290A5D92001A3B7C /* minimuxer */, + A8E2DB202D684CBD009E5D31 /* UITests */, ); }; /* End PBXProject section */ @@ -2643,6 +2711,14 @@ /* End PBXReferenceProxy section */ /* Begin PBXResourcesBuildPhase section */ + A8E2DB1F2D684CBD009E5D31 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A8E2DB342D68507F009E5D31 /* SideStoreTests.xctestplan in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; BF580479246A28F7008AE704 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -2814,6 +2890,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A8E2DB1D2D684CBD009E5D31 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A8E2DB312D684E2A009E5D31 /* UITests.swift in Sources */, + A8E2DB322D684E2A009E5D31 /* UITestsLaunchTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; BF4587282298D31600BD7491 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -3202,6 +3287,11 @@ target = BF58047A246A28F7008AE704 /* AltBackup */; targetProxy = A8E00D3D2D0C95B5000DD2C7 /* PBXContainerItemProxy */; }; + A8E2DB282D684CBD009E5D31 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = BFD247692284B9A500981D42 /* SideStore */; + targetProxy = A8E2DB272D684CBD009E5D31 /* PBXContainerItemProxy */; + }; BF66EE842501AE50007EE018 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = BF66EE7D2501AE50007EE018 /* AltStoreCore */; @@ -3365,6 +3455,59 @@ }; name = Release; }; + A8E2DB2A2D684CBD009E5D31 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A8E2DB2C2D684D39009E5D31 /* UITests.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "--PRODUCT-BUNDLE-IDENTIFIER-.UITests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = SideStore; + }; + name = Debug; + }; + A8E2DB2B2D684CBD009E5D31 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A8E2DB2C2D684D39009E5D31 /* UITests.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "--PRODUCT-BUNDLE-IDENTIFIER-.UITests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = SideStore; + }; + name = Release; + }; BF4587342298D31600BD7491 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -3900,6 +4043,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + A8E2DB292D684CBD009E5D31 /* Build configuration list for PBXNativeTarget "UITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A8E2DB2A2D684CBD009E5D31 /* Debug */, + A8E2DB2B2D684CBD009E5D31 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; BF4587332298D31600BD7491 /* Build configuration list for PBXNativeTarget "libimobiledevice" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/AltStore.xcodeproj/xcshareddata/xcschemes/SideStore.xcscheme b/AltStore.xcodeproj/xcshareddata/xcschemes/SideStore.xcscheme index 1a73712e..56b21a62 100644 --- a/AltStore.xcodeproj/xcshareddata/xcschemes/SideStore.xcscheme +++ b/AltStore.xcodeproj/xcshareddata/xcschemes/SideStore.xcscheme @@ -28,18 +28,27 @@ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES"> - + + + + + skipped = "NO"> + + + + diff --git a/SideStore/Tests/SideStoreTests.xctestplan b/SideStore/Tests/SideStoreTests.xctestplan new file mode 100644 index 00000000..d0177c64 --- /dev/null +++ b/SideStore/Tests/SideStoreTests.xctestplan @@ -0,0 +1,33 @@ +{ + "configurations" : [ + { + "id" : "93E5E265-DC67-47F3-A214-8082A3421288", + "name" : "Test Scheme Action", + "options" : { + + } + } + ], + "defaultOptions" : { + "targetForVariableExpansion" : { + "containerPath" : "container:AltStore.xcodeproj", + "identifier" : "BFD247692284B9A500981D42", + "name" : "SideStore" + } + }, + "testTargets" : [ + { + "skippedTests" : [ + "UITests\/testLaunchPerformance()", + "UITestsLaunchTests", + "UITestsLaunchTests\/testLaunch()" + ], + "target" : { + "containerPath" : "container:AltStore.xcodeproj", + "identifier" : "A8E2DB202D684CBD009E5D31", + "name" : "UITests" + } + } + ], + "version" : 1 +} diff --git a/SideStore/Tests/UITests/UITests.swift b/SideStore/Tests/UITests/UITests.swift new file mode 100644 index 00000000..694d99d6 --- /dev/null +++ b/SideStore/Tests/UITests/UITests.swift @@ -0,0 +1,44 @@ +// +// UITests.swift +// UITests +// +// Created by Magesh K on 21/02/25. +// Copyright © 2025 SideStore. All rights reserved. +// + +import XCTest + +final class UITests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + + // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + @MainActor + func testExample() throws { + // UI tests must launch the application that they test. + let app = XCUIApplication() + app.launch() + + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + +// @MainActor +// func testLaunchPerformance() throws { +// if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { +// // This measures how long it takes to launch your application. +// measure(metrics: [XCTApplicationLaunchMetric()]) { +// XCUIApplication().launch() +// } +// } +// } +} diff --git a/SideStore/Tests/UITests/UITestsLaunchTests.swift b/SideStore/Tests/UITests/UITestsLaunchTests.swift new file mode 100644 index 00000000..4ead3441 --- /dev/null +++ b/SideStore/Tests/UITests/UITestsLaunchTests.swift @@ -0,0 +1,34 @@ +// +// UITestsLaunchTests.swift +// UITests +// +// Created by Magesh K on 21/02/25. +// Copyright © 2025 SideStore. All rights reserved. +// + +import XCTest + +final class UITestsLaunchTests: XCTestCase { + + override class var runsForEachTargetApplicationUIConfiguration: Bool { + true + } + + override func setUpWithError() throws { + continueAfterFailure = false + } + +// @MainActor +// func testLaunch() throws { +// let app = XCUIApplication() +// app.launch() +// +// // Insert steps here to perform after app launch but before taking a screenshot, +// // such as logging into a test account or navigating somewhere in the app +// +// let attachment = XCTAttachment(screenshot: app.screenshot()) +// attachment.name = "Launch Screen" +// attachment.lifetime = .keepAlways +// add(attachment) +// } +} diff --git a/xcconfigs/UITests.xcconfig b/xcconfigs/UITests.xcconfig new file mode 100644 index 00000000..94c175d2 --- /dev/null +++ b/xcconfigs/UITests.xcconfig @@ -0,0 +1,3 @@ +#include "../Build.xcconfig" + +PRODUCT_BUNDLE_IDENTIFIER = $(PRODUCT_BUNDLE_IDENTIFIER).UITests From 1a43ad4aa3cea23ae0472af193cf9c48b435bd9e Mon Sep 17 00:00:00 2001 From: Magesh K <47920326+mahee96@users.noreply.github.com> Date: Fri, 21 Feb 2025 18:01:40 +0530 Subject: [PATCH 02/25] - UITests: Added new - testBulkAddRecommendedSources() --- SideStore/Tests/UITests/UITests.swift | 125 ++++++++++++++++++++++++-- 1 file changed, 117 insertions(+), 8 deletions(-) diff --git a/SideStore/Tests/UITests/UITests.swift b/SideStore/Tests/UITests/UITests.swift index 694d99d6..dbbe73bd 100644 --- a/SideStore/Tests/UITests/UITests.swift +++ b/SideStore/Tests/UITests/UITests.swift @@ -7,29 +7,72 @@ // import XCTest +import Foundation final class UITests: XCTestCase { - + + // Handle to the homescreen UI + private static let springboard_app = XCUIApplication(bundleIdentifier: "com.apple.springboard") + private static let spotlight_app = XCUIApplication(bundleIdentifier: "com.apple.Spotlight") + + private static let APP_NAME = "SideStore" + override func setUpWithError() throws { + // ignore spotlight it it was shown + Self.springboard_app.tap() + // Put setup code here. This method is called before the invocation of each test method in the class. - + // In UI tests it is usually best to stop immediately when a failure occurs. continueAfterFailure = false - + // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. } - + override func tearDownWithError() throws { // Put teardown code here. This method is called after the invocation of each test method in the class. + Self.deleteMyApp() + super.tearDown() } + + class func deleteMyApp() { + XCUIApplication().terminate() + dismissSpringboardAlerts() + +// XCUIDevice.shared.press(.home) + springboard_app.swipeDown() + + let searchBar = spotlight_app.textFields["SpotlightSearchField"] + searchBar.typeText(APP_NAME) - @MainActor - func testExample() throws { - // UI tests must launch the application that they test. + // Rest of the deletion flow... + let appIcon = spotlight_app.icons[APP_NAME] + if appIcon.waitForExistence(timeout: 0.2) { + appIcon.press(forDuration: 1) + + let deleteAppButton = spotlight_app.buttons["Delete App"] + deleteAppButton.tap() + + let confirmDeleteButton = springboard_app.alerts["Delete “\(APP_NAME)”?"] + confirmDeleteButton.scrollViews.otherElements.buttons["Delete"].tap() + } + searchBar.buttons["Clear text"].tap() + springboard_app.tap() + } + +// @MainActor // Xcode 16.2 bug: UITest Record Button Disabled with @MainActor, see: https://stackoverflow.com/a/79445950/11971304 + func testBulkAddRecommendedSources() throws { + let app = XCUIApplication() app.launch() - // Use XCTAssert and related functions to verify your tests produce the correct results. + let systemAlert = Self.springboard_app.alerts["“\(Self.APP_NAME)” Would Like to Send You Notifications"] + + XCTAssertTrue(systemAlert.waitForExistence(timeout: 0.2), "Notifications alert did not appear") + systemAlert.scrollViews.otherElements.buttons["Allow"].tap() + + // Do the actual validation + try performBulkAddingRecommendedSources(for: app) } // @MainActor @@ -42,3 +85,69 @@ final class UITests: XCTestCase { // } // } } + +private extension UITests { + class func dismissSpringboardAlerts() { + for alert in springboard_app.alerts.allElementsBoundByIndex { + if alert.exists { + // If there's a "Cancel" button, tap it; otherwise, tap the first button. + if alert.buttons["Cancel"].exists { + alert.buttons["Cancel"].tap() + } else if let firstButton = alert.buttons.allElementsBoundByIndex.first { + firstButton.tap() + } + } + } + } +} + + + +private extension UITests { + + private func performBulkAddingRecommendedSources(for app: XCUIApplication) throws { + // Navigate to the Sources screen and open the Add Source view. + app.tabBars["Tab Bar"].buttons["Sources"].tap() + app.navigationBars["Sources"].buttons["Add"].tap() + + let cellsQuery = app.collectionViews.cells + + // Data model for recommended sources. NOTE: This list order is required to be the same as that of "Add Source" Screen + let recommendedSources: [(identifier: String, alertTitle: String, requiresSwipe: Bool)] = [ + ("SideStore Team Picks\ncommunity-apps.sidestore.io/sidecommunity.json", "SideStore Team Picks", false), + ("Provenance EMU\nprovenance-emu.com/apps.json", "Provenance EMU", false), +// ("Countdown Respository\nneoarz.github.io/Countdown-App/Countdown.json", "Countdown Respository", false), +// ("OatmealDome's AltStore Source\naltstore.oatmealdome.me", "OatmealDome's AltStore Source", false), +// ("UTM Repository\nVirtual machines for iOS", "UTM Repository", true), +// ("Flyinghead\nflyinghead.github.io/flycast-builds/altstore.json", "Flyinghead", false), +// ("PojavLauncher Repository\nalt.crystall1ne.dev", "PojavLauncher Repository", true), +// ("PokeMMO\npokemmo.eu/altstore/", "PokeMMO", false), +// ("Odyssey\ntheodyssey.dev/altstore/odysseysource.json", "Odyssey", false), +// ("Yattee\nrepos.yattee.stream/alt/apps.json", "Yattee", false), +// ("ThatStella7922 Source\nThe home for all apps ThatStella7922", "ThatStella7922 Source", false) + ] + + // Tap on each recommended source's "add" button. + for source in recommendedSources { + let sourceButton = cellsQuery.otherElements + .containing(.button, identifier: source.identifier) + .children(matching: .button)[source.identifier] + let addButton = sourceButton.children(matching: .button)["add"] + addButton.tap() + if source.requiresSwipe { + sourceButton.swipeUp() // Swipe up if needed. + } + } + + // Commit the changes by tapping "Done". + app.navigationBars["Add Source"].buttons["Done"].tap() + + // Accept each source addition via alert. + for source in recommendedSources { + let alertIdentifier = "Would you like to add the source “\(source.alertTitle)”?" + app.alerts[alertIdentifier] + .scrollViews.otherElements.buttons["Add Source"] + .tap() + } + } +} From 08e11eece42a3a2808296998b74d6de74923280c Mon Sep 17 00:00:00 2001 From: Magesh K <47920326+mahee96@users.noreply.github.com> Date: Fri, 21 Feb 2025 19:03:06 +0530 Subject: [PATCH 03/25] - DataStructures: Implemented LinkedHashMap and TreeMap (RB Tree) in swift --- .../Utils/datastructures/LinkedHashMap.swift | 226 ++++++++++ SideStore/Utils/datastructures/TreeMap.swift | 397 ++++++++++++++++++ 2 files changed, 623 insertions(+) create mode 100644 SideStore/Utils/datastructures/LinkedHashMap.swift create mode 100644 SideStore/Utils/datastructures/TreeMap.swift diff --git a/SideStore/Utils/datastructures/LinkedHashMap.swift b/SideStore/Utils/datastructures/LinkedHashMap.swift new file mode 100644 index 00000000..70d1819e --- /dev/null +++ b/SideStore/Utils/datastructures/LinkedHashMap.swift @@ -0,0 +1,226 @@ +// +// LinkedHashMap.swift +// SideStore +// +// Created by Magesh K on 21/02/25. +// Copyright © 2025 SideStore. All rights reserved. +// + + +/// A generic LinkedHashMap implementation in Swift. +/// It provides constant-time lookup along with predictable (insertion) ordering. +public final class LinkedHashMap: Sequence { + + /// Internal doubly-linked list node + fileprivate final class Node { + let key: Key + var value: Value + var next: Node? + weak var prev: Node? // weak to avoid strong reference cycle + + init(key: Key, value: Value) { + self.key = key + self.value = value + } + } + + // MARK: - Storage + + /// Dictionary for fast lookup from key to node. + private var dict: [Key: Node] = [:] + + /// Head and tail of the doubly-linked list to maintain order. + private var head: Node? + private var tail: Node? + + // MARK: - Initialization + + /// Creates an empty LinkedHashMap. + public init() { } + + /// Creates a LinkedHashMap from a standard dictionary. + public init(_ dictionary: [Key: Value]) { + for (key, value) in dictionary { + _ = self.put(key: key, value: value) + } + } + + // MARK: - Public API + + /// The number of key-value pairs in the map. + public var count: Int { + return dict.count + } + + /// A Boolean value indicating whether the map is empty. + public var isEmpty: Bool { + return dict.isEmpty + } + + /// Returns the value for the given key, or `nil` if the key is not found. + public func get(key: Key) -> Value? { + return dict[key]?.value + } + + /// Inserts or updates the value for the given key. + /// - Returns: The previous value for the key if it existed; otherwise, `nil`. + @discardableResult + public func put(key: Key, value: Value) -> Value? { + if let node = dict[key] { + let oldValue = node.value + node.value = value + return oldValue + } else { + let newNode = Node(key: key, value: value) + dict[key] = newNode + appendNode(newNode) + return nil + } + } + + /// Removes the value for the given key. + /// - Returns: The removed value if it existed; otherwise, `nil`. + @discardableResult + public func remove(key: Key) -> Value? { + guard let node = dict.removeValue(forKey: key) else { return nil } + removeNode(node) + return node.value + } + + /// Removes all key-value pairs from the map. + public func clear() { + dict.removeAll() + head = nil + tail = nil + } + + /// Determines whether the map contains the given key. + public func containsKey(_ key: Key) -> Bool { + return dict[key] != nil + } + + /// Determines whether the map contains the given value. + /// Note: This method requires that Value conforms to Equatable. + public func containsValue(_ value: Value) -> Bool where Value: Equatable { + var current = head + while let node = current { + if node.value == value { + return true + } + current = node.next + } + return false + } + + /// Returns all keys in insertion order. + public var keys: [Key] { + var result = [Key]() + var current = head + while let node = current { + result.append(node.key) + current = node.next + } + return result + } + + /// Returns all values in insertion order. + public var values: [Value] { + var result = [Value]() + var current = head + while let node = current { + result.append(node.value) + current = node.next + } + return result + } + + /// Subscript for getting and setting values. + public subscript(key: Key) -> Value? { + get { + return get(key: key) + } + set { + if let newValue = newValue { + _ = put(key: key, value: newValue) + } else { + _ = remove(key: key) + } + } + } + + // MARK: - Sequence Conformance + + /// Iterator that yields key-value pairs in insertion order. + public struct Iterator: IteratorProtocol { + private var current: Node? + + fileprivate init(start: Node?) { + self.current = start + } + + public mutating func next() -> (key: Key, value: Value)? { + guard let node = current else { return nil } + current = node.next + return (node.key, node.value) + } + } + + public func makeIterator() -> Iterator { + return Iterator(start: head) + } + + // MARK: - Private Helpers + + /// Appends a new node to the end of the linked list. + private func appendNode(_ node: Node) { + if let tailNode = tail { + tailNode.next = node + node.prev = tailNode + tail = node + } else { + head = node + tail = node + } + } + + /// Removes the given node from the linked list. + private func removeNode(_ node: Node) { + let prevNode = node.prev + let nextNode = node.next + + if let prevNode = prevNode { + prevNode.next = nextNode + } else { + head = nextNode + } + + if let nextNode = nextNode { + nextNode.prev = prevNode + } else { + tail = prevNode + } + + // Disconnect node's pointers. + node.prev = nil + node.next = nil + } + + public func removeValue(forKey key: Key) -> Value? { + return remove(key: key) + } +} + +extension LinkedHashMap { + public subscript(key: Key, default defaultValue: @autoclosure () -> Value) -> Value { + get { + if let value = self[key] { + return value + } else { + return defaultValue() + } + } + set { + self[key] = newValue + } + } +} diff --git a/SideStore/Utils/datastructures/TreeMap.swift b/SideStore/Utils/datastructures/TreeMap.swift new file mode 100644 index 00000000..7afca202 --- /dev/null +++ b/SideStore/Utils/datastructures/TreeMap.swift @@ -0,0 +1,397 @@ +// +// TreeMap.swift +// SideStore +// +// Created by Magesh K on 21/02/25. +// Copyright © 2025 SideStore. All rights reserved. +// + +public class TreeMap: Sequence { + + // MARK: - Node and Color Definitions + + fileprivate enum Color { + case red + case black + } + + fileprivate class Node { + var key: Key + var value: Value + var left: Node? + var right: Node? + weak var parent: Node? + var color: Color + + init(key: Key, value: Value, color: Color = .red, parent: Node? = nil) { + self.key = key + self.value = value + self.color = color + self.parent = parent + } + } + + // MARK: - TreeMap Properties and Initializer + + private var root: Node? + public private(set) var count: Int = 0 + + public init() {} + + // MARK: - Public Dictionary-like API + + /// Subscript: Get or set value for a given key. + public subscript(key: Key) -> Value? { + get { return get(key: key) } + set { + if let newValue = newValue { + _ = insert(key: key, value: newValue) + } else { + _ = remove(key: key) + } + } + } + + /// Returns the value associated with the given key. + public func get(key: Key) -> Value? { + guard let node = getNode(forKey: key) else { return nil } + return node.value + } + + /// Inserts (or updates) the key with the given value. + /// Returns the old value if the key was already present. + @discardableResult + public func insert(key: Key, value: Value) -> Value? { + if let node = getNode(forKey: key) { + let oldValue = node.value + node.value = value + return oldValue + } + // Create new node + let newNode = Node(key: key, value: value) + var parent: Node? = nil + var current = root + while let cur = current { + parent = cur + if newNode.key < cur.key { + current = cur.left + } else { + current = cur.right + } + } + newNode.parent = parent + if parent == nil { + root = newNode + } else if newNode.key < parent!.key { + parent!.left = newNode + } else { + parent!.right = newNode + } + count += 1 + fixAfterInsertion(newNode) + return nil + } + + /// Removes the node with the given key. + /// Returns the removed value if it existed. + @discardableResult + public func remove(key: Key) -> Value? { + guard let node = getNode(forKey: key) else { return nil } + let removedValue = node.value + deleteNode(node) + count -= 1 + return removedValue + } + + /// Returns true if the map is empty. + public var isEmpty: Bool { + return count == 0 + } + + /// Returns all keys in sorted order. + public var keys: [Key] { + var result = [Key]() + for (k, _) in self { result.append(k) } + return result + } + + /// Returns all values in order of their keys. + public var values: [Value] { + var result = [Value]() + for (_, v) in self { result.append(v) } + return result + } + + /// Removes all entries. + public func removeAll() { + root = nil + count = 0 + } + + // MARK: - Internal Helper Methods + + /// Standard BST search for a node matching the key. + private func getNode(forKey key: Key) -> Node? { + var current = root + while let node = current { + if key == node.key { + return node + } else if key < node.key { + current = node.left + } else { + current = node.right + } + } + return nil + } + + /// Returns the minimum node in the subtree rooted at `node`. + private func minimum(_ node: Node) -> Node { + var current = node + while let next = current.left { + current = next + } + return current + } + + // MARK: - Rotation Methods + + private func rotateLeft(_ x: Node) { + guard let y = x.right else { return } + x.right = y.left + if let leftChild = y.left { + leftChild.parent = x + } + y.parent = x.parent + if x.parent == nil { + root = y + } else if x === x.parent?.left { + x.parent?.left = y + } else { + x.parent?.right = y + } + y.left = x + x.parent = y + } + + private func rotateRight(_ x: Node) { + guard let y = x.left else { return } + x.left = y.right + if let rightChild = y.right { + rightChild.parent = x + } + y.parent = x.parent + if x.parent == nil { + root = y + } else if x === x.parent?.right { + x.parent?.right = y + } else { + x.parent?.left = y + } + y.right = x + x.parent = y + } + + // MARK: - Insertion Fix-Up + + /// Restores red–black properties after insertion. + private func fixAfterInsertion(_ x: Node) { + var node = x + node.color = .red + while node !== root, let parent = node.parent, parent.color == .red { + if parent === parent.parent?.left { + if let uncle = parent.parent?.right, uncle.color == .red { + parent.color = .black + uncle.color = .black + parent.parent?.color = .red + if let grandparent = parent.parent { + node = grandparent + } + } else { + if node === parent.right { + node = parent + rotateLeft(node) + } + node.parent?.color = .black + node.parent?.parent?.color = .red + if let grandparent = node.parent?.parent { + rotateRight(grandparent) + } + } + } else { + if let uncle = parent.parent?.left, uncle.color == .red { + parent.color = .black + uncle.color = .black + parent.parent?.color = .red + if let grandparent = parent.parent { + node = grandparent + } + } else { + if node === parent.left { + node = parent + rotateRight(node) + } + node.parent?.color = .black + node.parent?.parent?.color = .red + if let grandparent = node.parent?.parent { + rotateLeft(grandparent) + } + } + } + } + root?.color = .black + } + + // MARK: - Deletion Helpers + + /// Replaces subtree rooted at u with subtree rooted at v. + private func transplant(_ u: Node, _ v: Node?) { + if u.parent == nil { + root = v + } else if u === u.parent?.left { + u.parent?.left = v + } else { + u.parent?.right = v + } + if let vNode = v { + vNode.parent = u.parent + } + } + + /// Deletes node z and fixes red–black properties. + private func deleteNode(_ z: Node) { + var y = z + let originalColor = y.color + var x: Node? + + if z.left == nil { + x = z.right + transplant(z, z.right) + } else if z.right == nil { + x = z.left + transplant(z, z.left) + } else { + y = minimum(z.right!) + let yOriginalColor = y.color + x = y.right + if y.parent === z { + if x != nil { x!.parent = y } + } else { + transplant(y, y.right) + y.right = z.right + y.right?.parent = y + } + transplant(z, y) + y.left = z.left + y.left?.parent = y + y.color = z.color + if yOriginalColor == .black { + fixAfterDeletion(x, parent: y.parent) + } + return + } + if originalColor == .black { + fixAfterDeletion(x, parent: z.parent) + } + } + + /// Restores red–black properties after deletion. + private func fixAfterDeletion(_ x: Node?, parent: Node?) { + var x = x + var parent = parent + while (x == nil || x!.color == .black) && (x !== root) { + if x === parent?.left { + var w = parent?.right + if w?.color == .red { + w?.color = .black + parent?.color = .red + rotateLeft(parent!) + w = parent?.right + } + if (w?.left == nil || w?.left?.color == .black) && + (w?.right == nil || w?.right?.color == .black) { + w?.color = .red + x = parent + parent = x?.parent + } else { + if w?.right == nil || w?.right?.color == .black { + w?.left?.color = .black + w?.color = .red + if let wUnwrapped = w { rotateRight(wUnwrapped) } + w = parent?.right + } + w?.color = parent?.color ?? .black + parent?.color = .black + w?.right?.color = .black + rotateLeft(parent!) + x = root + parent = nil + } + } else { + var w = parent?.left + if w?.color == .red { + w?.color = .black + parent?.color = .red + rotateRight(parent!) + w = parent?.left + } + if (w?.left == nil || w?.left?.color == .black) && + (w?.right == nil || w?.right?.color == .black) { + w?.color = .red + x = parent + parent = x?.parent + } else { + if w?.left == nil || w?.left?.color == .black { + w?.right?.color = .black + w?.color = .red + if let wUnwrapped = w { rotateLeft(wUnwrapped) } + w = parent?.left + } + w?.color = parent?.color ?? .black + parent?.color = .black + w?.left?.color = .black + rotateRight(parent!) + x = root + parent = nil + } + } + } + x?.color = .black + } + + // Convenience overload if parent is not separately tracked. + private func fixAfterDeletion(_ x: Node?) { + fixAfterDeletion(x, parent: x?.parent) + } + + // MARK: - Sequence Conformance (In-Order Traversal) + + public struct Iterator: IteratorProtocol { + private var stack: [Node] = [] + + // Marked as private because Node is a private type. + fileprivate init(root: Node?) { + var current = root + while let node = current { + stack.append(node) + current = node.left + } + } + + public mutating func next() -> (Key, Value)? { + if stack.isEmpty { return nil } + let node = stack.removeLast() + let result = (node.key, node.value) + var current = node.right + while let n = current { + stack.append(n) + current = n.left + } + return result + } + } + + public func makeIterator() -> Iterator { + return Iterator(root: root) + } +} From 15f4ae7b5a1b222d6c53bc176713eafd37f1283d Mon Sep 17 00:00:00 2001 From: Magesh K <47920326+mahee96@users.noreply.github.com> Date: Fri, 21 Feb 2025 19:04:40 +0530 Subject: [PATCH 04/25] - UnitTests: Added unitttests for new datastructures - LinkedHashMap and TreeMap --- AltStore.xcodeproj/project.pbxproj | 40 +++- SideStore/Tests/SideStoreTests.xctestplan | 2 + .../datastructures/LinkedHashMapTests.swift | 206 ++++++++++++++++++ .../datastructures/TreeMapTests.swift | 154 +++++++++++++ 4 files changed, 400 insertions(+), 2 deletions(-) create mode 100644 SideStore/Tests/UnitTests/datastructures/LinkedHashMapTests.swift create mode 100644 SideStore/Tests/UnitTests/datastructures/TreeMapTests.swift diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index 49adbfa0..accb2efe 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -59,12 +59,18 @@ A80D60D32D3DD85100CEF65D /* ReleaseTrack.swift in Sources */ = {isa = PBXBuildFile; fileRef = A80D60D12D3D705F00CEF65D /* ReleaseTrack.swift */; }; A80D790D2D2F20AF00A40F40 /* PaginationIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A80D790C2D2F20AF00A40F40 /* PaginationIntent.swift */; }; A80D790F2D2F217000A40F40 /* PaginationDataHolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A80D790E2D2F217000A40F40 /* PaginationDataHolder.swift */; }; + A81A8CB52D68B2180086C96F /* TreeMapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A81A8CB42D68B2180086C96F /* TreeMapTests.swift */; }; + A81A8CB92D68B30B0086C96F /* SingletonGenericMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A868CFE32D319988002F1201 /* SingletonGenericMap.swift */; }; + A81A8CBA2D68B3110086C96F /* TreeMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A81A8CB02D68B0320086C96F /* TreeMap.swift */; }; + A81A8CBB2D68B3230086C96F /* TreeMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A81A8CB02D68B0320086C96F /* TreeMap.swift */; }; + A81A8CBD2D68B43F0086C96F /* LinkedHashMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A81A8CBC2D68B43F0086C96F /* LinkedHashMap.swift */; }; + A81A8CBE2D68B43F0086C96F /* LinkedHashMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A81A8CBC2D68B43F0086C96F /* LinkedHashMap.swift */; }; + A81A8CC02D68B4520086C96F /* LinkedHashMapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A81A8CBF2D68B4520086C96F /* LinkedHashMapTests.swift */; }; A82067842D03DC0600645C0D /* OperatingSystemVersion+Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5708416292448DA00D42D34 /* OperatingSystemVersion+Comparable.swift */; }; A82067C42D03E0DE00645C0D /* SemanticVersion in Frameworks */ = {isa = PBXBuildFile; productRef = A82067C32D03E0DE00645C0D /* SemanticVersion */; }; A859ED5C2D1EE827003DCC58 /* OpenSSL.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A859ED5B2D1EE80D003DCC58 /* OpenSSL.xcframework */; }; A859ED5D2D1EE827003DCC58 /* OpenSSL.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = A859ED5B2D1EE80D003DCC58 /* OpenSSL.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; A86315DF2D3EB2DE0048FA40 /* ErrorProcessing.swift in Sources */ = {isa = PBXBuildFile; fileRef = A86315DE2D3EB2D80048FA40 /* ErrorProcessing.swift */; }; - A868CFE42D31999A002F1201 /* SingletonGenericMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A868CFE32D319988002F1201 /* SingletonGenericMap.swift */; }; A8696EE42D34512C00E96389 /* RemoveAppExtensionsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8696EE32D34512C00E96389 /* RemoveAppExtensionsOperation.swift */; }; A88B8C492D35AD3200F53F9D /* OperationsLoggingContolView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A88B8C482D35AD3200F53F9D /* OperationsLoggingContolView.swift */; }; A88B8C552D35F1EC00F53F9D /* OperationsLoggingControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = A88B8C542D35F1EC00F53F9D /* OperationsLoggingControl.swift */; }; @@ -650,6 +656,10 @@ A80D60D12D3D705F00CEF65D /* ReleaseTrack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReleaseTrack.swift; sourceTree = ""; }; A80D790C2D2F20AF00A40F40 /* PaginationIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationIntent.swift; sourceTree = ""; }; A80D790E2D2F217000A40F40 /* PaginationDataHolder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationDataHolder.swift; sourceTree = ""; }; + A81A8CB02D68B0320086C96F /* TreeMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TreeMap.swift; sourceTree = ""; }; + A81A8CB42D68B2180086C96F /* TreeMapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TreeMapTests.swift; sourceTree = ""; }; + A81A8CBC2D68B43F0086C96F /* LinkedHashMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkedHashMap.swift; sourceTree = ""; }; + A81A8CBF2D68B4520086C96F /* LinkedHashMapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkedHashMapTests.swift; sourceTree = ""; }; A859ED5B2D1EE80D003DCC58 /* OpenSSL.xcframework */ = {isa = PBXFileReference; expectedSignature = "AppleDeveloperProgram:67RAULRX93:Marcin Krzyzanowski"; lastKnownFileType = wrapper.xcframework; name = OpenSSL.xcframework; path = SideStore/AltSign/Dependencies/OpenSSL/Frameworks/OpenSSL.xcframework; sourceTree = ""; }; A85ACB8E2D1F31C400AA3DE7 /* AltBackup.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AltBackup.xcconfig; sourceTree = ""; }; A85ACB8F2D1F31C400AA3DE7 /* AltStore.debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AltStore.debug.xcconfig; sourceTree = ""; }; @@ -1227,6 +1237,23 @@ path = Intents; sourceTree = ""; }; + A81A8CB22D68B2030086C96F /* UnitTests */ = { + isa = PBXGroup; + children = ( + A81A8CB32D68B20F0086C96F /* datastructures */, + ); + path = UnitTests; + sourceTree = ""; + }; + A81A8CB32D68B20F0086C96F /* datastructures */ = { + isa = PBXGroup; + children = ( + A81A8CBF2D68B4520086C96F /* LinkedHashMapTests.swift */, + A81A8CB42D68B2180086C96F /* TreeMapTests.swift */, + ); + path = datastructures; + sourceTree = ""; + }; A85ACB942D1F31C400AA3DE7 /* xcconfigs */ = { isa = PBXGroup; children = ( @@ -1289,6 +1316,8 @@ A8AD35572D31BEB2003A28B4 /* datastructures */ = { isa = PBXGroup; children = ( + A81A8CBC2D68B43F0086C96F /* LinkedHashMap.swift */, + A81A8CB02D68B0320086C96F /* TreeMap.swift */, A868CFE32D319988002F1201 /* SingletonGenericMap.swift */, ); path = datastructures; @@ -1357,6 +1386,7 @@ A8E2DB352D6850A9009E5D31 /* Tests */ = { isa = PBXGroup; children = ( + A81A8CB22D68B2030086C96F /* UnitTests */, A8E2DB302D684E2A009E5D31 /* UITests */, A8E2DB332D68507F009E5D31 /* SideStoreTests.xctestplan */, ); @@ -2895,7 +2925,11 @@ buildActionMask = 2147483647; files = ( A8E2DB312D684E2A009E5D31 /* UITests.swift in Sources */, + A81A8CB52D68B2180086C96F /* TreeMapTests.swift in Sources */, + A81A8CBB2D68B3230086C96F /* TreeMap.swift in Sources */, A8E2DB322D684E2A009E5D31 /* UITestsLaunchTests.swift in Sources */, + A81A8CC02D68B4520086C96F /* LinkedHashMapTests.swift in Sources */, + A81A8CBE2D68B43F0086C96F /* LinkedHashMap.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3100,6 +3134,7 @@ files = ( D55467C52A8D72C300F4CE90 /* ActiveAppsWidget.swift in Sources */, D577AB7B2A967DF5007FE952 /* AppsTimelineProvider.swift in Sources */, + A81A8CB92D68B30B0086C96F /* SingletonGenericMap.swift in Sources */, D577AB7F2A96878A007FE952 /* AppDetailWidget.swift in Sources */, BF98917E250AAC4F002ACF50 /* Countdown.swift in Sources */, D5151BE22A90363300C96F28 /* RefreshAllAppsWidgetIntent.swift in Sources */, @@ -3108,7 +3143,6 @@ D5FD4EC92A9530C00097BEE8 /* AppSnapshot.swift in Sources */, A8AD35592D31BF2C003A28B4 /* PageInfoManager.swift in Sources */, D5151BE72A90395400C96F28 /* View+AltWidget.swift in Sources */, - A868CFE42D31999A002F1201 /* SingletonGenericMap.swift in Sources */, A8A853AF2D3065A300995795 /* ActiveAppsTimelineProvider.swift in Sources */, BF98917F250AAC4F002ACF50 /* LockScreenWidget.swift in Sources */, A800F7042CE28E3800208744 /* View+AltWidget.swift in Sources */, @@ -3142,6 +3176,7 @@ D5E1E7C128077DE90016FC96 /* UpdateKnownSourcesOperation.swift in Sources */, BFE338DF22F0EADB002E24B9 /* FetchSourceOperation.swift in Sources */, D54DED1428CBC44B008B27A0 /* ErrorLogTableViewCell.swift in Sources */, + A81A8CBD2D68B43F0086C96F /* LinkedHashMap.swift in Sources */, D5390C3C2AC3A43900D17E62 /* AddSourceViewController.swift in Sources */, D5CD805F29CA755E00E591B0 /* SourceDetailViewController.swift in Sources */, D57968CB29CB99EF00539069 /* VibrantButton.swift in Sources */, @@ -3224,6 +3259,7 @@ BF770E5122BB1CF6002A40FE /* InstallAppOperation.swift in Sources */, BF9ABA4B22DD1380008935CF /* NavigationBar.swift in Sources */, A8FD917B2D0472DD00322782 /* DeprecatedAPIs.swift in Sources */, + A81A8CBA2D68B3110086C96F /* TreeMap.swift in Sources */, BF6C8FAC242935ED00125131 /* NSAttributedString+Markdown.m in Sources */, A8C38C382D2084D000E83DBD /* ConsoleLogView.swift in Sources */, BFF00D322501BDA100746320 /* BackgroundRefreshAppsOperation.swift in Sources */, diff --git a/SideStore/Tests/SideStoreTests.xctestplan b/SideStore/Tests/SideStoreTests.xctestplan index d0177c64..1b37c370 100644 --- a/SideStore/Tests/SideStoreTests.xctestplan +++ b/SideStore/Tests/SideStoreTests.xctestplan @@ -18,6 +18,8 @@ "testTargets" : [ { "skippedTests" : [ + "UITests", + "UITests\/testBulkAddRecommendedSources()", "UITests\/testLaunchPerformance()", "UITestsLaunchTests", "UITestsLaunchTests\/testLaunch()" diff --git a/SideStore/Tests/UnitTests/datastructures/LinkedHashMapTests.swift b/SideStore/Tests/UnitTests/datastructures/LinkedHashMapTests.swift new file mode 100644 index 00000000..fa2c326b --- /dev/null +++ b/SideStore/Tests/UnitTests/datastructures/LinkedHashMapTests.swift @@ -0,0 +1,206 @@ +// +// LinkedHashMapTests.swift +// SideStore +// +// Created by Magesh K on 21/02/25. +// Copyright © 2025 SideStore. All rights reserved. +// + +import XCTest + +// A helper class that signals when it is deallocated. +class LeakTester { + let id: Int + var onDeinit: (() -> Void)? + init(id: Int, onDeinit: (() -> Void)? = nil) { + self.id = id + self.onDeinit = onDeinit + } + deinit { + onDeinit?() + } +} + +final class LinkedHashMapTests: XCTestCase { + + // Test that insertion preserves order and that iteration returns items in insertion order. + func testInsertionAndOrder() { + let map = LinkedHashMap() + map.put(key: "one", value: 1) + map.put(key: "two", value: 2) + map.put(key: "three", value: 3) + + XCTAssertEqual(map.count, 3) + XCTAssertEqual(map.keys, ["one", "two", "three"], "Insertion order should be preserved") + + var iteratedKeys = [String]() + for (key, _) in map { + iteratedKeys.append(key) + } + XCTAssertEqual(iteratedKeys, ["one", "two", "three"], "Iterator should follow insertion order") + } + + // Test that updating a key does not change its order. + func testUpdateDoesNotChangeOrder() { + let map = LinkedHashMap() + map.put(key: "a", value: 1) + map.put(key: "b", value: 2) + map.put(key: "c", value: 3) + // Update key "b" + map.put(key: "b", value: 20) + XCTAssertEqual(map.get(key: "b"), 20) + + XCTAssertEqual(map.keys, ["a", "b", "c"], "Order should not change on update") + } + + // Test removal functionality and behavior when removing a non-existent key. + func testRemoval() { + let map = LinkedHashMap() + map.put(key: 1, value: "one") + map.put(key: 2, value: "two") + map.put(key: 3, value: "three") + + let removed = map.remove(key: 2) + XCTAssertEqual(removed, "two") + XCTAssertEqual(map.count, 2) + XCTAssertEqual(map.keys, [1, 3]) + + // Removing a key that doesn't exist should return nil. + let removedNil = map.remove(key: 4) + XCTAssertNil(removedNil) + } + + // Test clearing the map. + func testClear() { + let map = LinkedHashMap() + map.put(key: "x", value: 100) + map.put(key: "y", value: 200) + XCTAssertEqual(map.count, 2) + + map.clear() + XCTAssertEqual(map.count, 0) + XCTAssertTrue(map.isEmpty) + XCTAssertEqual(map.keys, []) + XCTAssertEqual(map.values, []) + } + + // Test subscript access for getting, updating, and removal. + func testSubscript() { + let map = LinkedHashMap() + map["alpha"] = 10 + XCTAssertEqual(map["alpha"], 10) + + map["alpha"] = 20 + XCTAssertEqual(map["alpha"], 20) + + // Setting a key to nil should remove the mapping. + map["alpha"] = nil + XCTAssertNil(map["alpha"]) + } + + // Test containsKey and containsValue. + func testContains() { + let map = LinkedHashMap() + map.put(key: "key1", value: 1) + map.put(key: "key2", value: 2) + + XCTAssertTrue(map.containsKey("key1")) + XCTAssertFalse(map.containsKey("key3")) + + XCTAssertTrue(map.containsValue(1)) + XCTAssertFalse(map.containsValue(99)) + } + + // Test initialization from a dictionary. + func testInitializationFromDictionary() { + // Note: Swift dictionaries preserve insertion order for literals. + let dictionary: [String: Int] = ["a": 1, "b": 2, "c": 3] + let map = LinkedHashMap(dictionary) + XCTAssertEqual(map.count, 3) + // Order may differ since Dictionary order is not strictly defined – here we verify membership. + XCTAssertEqual(Set(map.keys), Set(["a", "b", "c"])) + } + + // Revised test that iterates over the map and compares key-value pairs element by element. + func testIteration() { + let map = LinkedHashMap() + let pairs = [(1, "one"), (2, "two"), (3, "three")] + for (key, value) in pairs { + map.put(key: key, value: value) + } + + var iteratedPairs = [(Int, String)]() + for (key, value) in map { + iteratedPairs.append((key, value)) + } + + XCTAssertEqual(iteratedPairs.count, pairs.count, "Iterated count should match inserted count") + for (iter, expected) in zip(iteratedPairs, pairs) { + XCTAssertEqual(iter.0, expected.0, "Keys should match in order") + XCTAssertEqual(iter.1, expected.1, "Values should match in order") + } + } + + // Test that the values stored in the map are deallocated when the map is deallocated. + func testMemoryLeak() { + weak var weakMap: LinkedHashMap? + var deinitCalled = false + + do { + let map = LinkedHashMap() + let tester = LeakTester(id: 1) { deinitCalled = true } + map.put(key: 1, value: tester) + weakMap = map + XCTAssertNotNil(map.get(key: 1)) + } + // At this point the map (and its stored objects) should be deallocated. + XCTAssertNil(weakMap, "LinkedHashMap should be deallocated when out of scope") + XCTAssertTrue(deinitCalled, "LeakTester should be deallocated, indicating no memory leak") + } + + // Test that removal from the map correctly frees stored objects. + func testMemoryLeakOnRemoval() { + var deinitCalledForTester1 = false + var deinitCalledForTester2 = false + + let map = LinkedHashMap() + autoreleasepool { + let tester1 = LeakTester(id: 1) { deinitCalledForTester1 = true } + let tester2 = LeakTester(id: 2) { deinitCalledForTester2 = true } + map.put(key: 1, value: tester1) + map.put(key: 2, value: tester2) + + XCTAssertNotNil(map.get(key: 1)) + XCTAssertNotNil(map.get(key: 2)) + + // Remove tester1; it should be deallocated if no retain cycle exists. + _ = map.remove(key: 1) + } + // tester1 should be deallocated immediately after removal. + XCTAssertTrue(deinitCalledForTester1, "Tester1 should be deallocated after removal") + // tester2 is still in the map. + XCTAssertNotNil(map.get(key: 2)) + + // Clear the map and tester2 should be deallocated. + map.clear() + XCTAssertTrue(deinitCalledForTester2, "Tester2 should be deallocated after clearing the map") + } + + func testDefaultSubscriptExtension() { + // Create an instance of LinkedHashMap with String keys and Bool values. + let map = LinkedHashMap() + + // Verify that accessing a non-existent key returns the default value (false). + XCTAssertEqual(map["testKey", default: false], false) + + // Use the default subscript setter to assign 'true' for the key. + map["testKey", default: false] = true + XCTAssertEqual(map["testKey", default: false], true) + + // Simulate in-place toggle: read the value, toggle it, then write it back. + var current = map["testKey", default: false] + current.toggle() // now false + map["testKey", default: false] = current + XCTAssertEqual(map["testKey", default: false], false) + } +} diff --git a/SideStore/Tests/UnitTests/datastructures/TreeMapTests.swift b/SideStore/Tests/UnitTests/datastructures/TreeMapTests.swift new file mode 100644 index 00000000..11881d08 --- /dev/null +++ b/SideStore/Tests/UnitTests/datastructures/TreeMapTests.swift @@ -0,0 +1,154 @@ +// +// TreeMapTests.swift +// AltStore +// +// Created by Magesh K on 21/02/25. +// Copyright © 2025 SideStore. All rights reserved. +// + + +import XCTest + +class TreeMapTests: XCTestCase { + + func testInsertionAndRetrieval() { + let map = TreeMap() + XCTAssertNil(map[10]) + map[10] = "ten" + XCTAssertEqual(map[10], "ten") + + map[5] = "five" + map[15] = "fifteen" + XCTAssertEqual(map.count, 3) + XCTAssertEqual(map[5], "five") + XCTAssertEqual(map[15], "fifteen") + } + + func testUpdateValue() { + let map = TreeMap() + map[10] = "ten" + let oldValue = map.insert(key: 10, value: "TEN") + XCTAssertEqual(oldValue, "ten") + XCTAssertEqual(map[10], "TEN") + XCTAssertEqual(map.count, 1) + } + + func testDeletion() { + let map = TreeMap() + // Setup: Inserting three nodes. + map[20] = "twenty" + map[10] = "ten" + map[30] = "thirty" + + // Remove a leaf node. + let removedLeaf = map.remove(key: 10) + XCTAssertEqual(removedLeaf, "ten") + XCTAssertNil(map[10]) + XCTAssertEqual(map.count, 2) + + // Setup additional nodes to create a one-child scenario. + map[25] = "twenty-five" + map[27] = "twenty-seven" // Right child for 25. + // Remove a node with one child. + let removedOneChild = map.remove(key: 25) + XCTAssertEqual(removedOneChild, "twenty-five") + XCTAssertNil(map[25]) + XCTAssertEqual(map.count, 3) + + // Setup for a node with two children. + map[40] = "forty" + map[35] = "thirty-five" + map[45] = "forty-five" + // Remove a node with two children. + let removedTwoChildren = map.remove(key: 40) + XCTAssertEqual(removedTwoChildren, "forty") + XCTAssertNil(map[40]) + XCTAssertEqual(map.count, 5) + } + + func testDeletionOfRoot() { + let map = TreeMap() + map[50] = "fifty" + map[30] = "thirty" + map[70] = "seventy" + + // Delete the root node. + let removedRoot = map.remove(key: 50) + XCTAssertEqual(removedRoot, "fifty") + XCTAssertNil(map[50]) + // After deletion, remaining keys should be in sorted order. + XCTAssertEqual(map.keys, [30, 70]) + } + + func testSortedIteration() { + let map = TreeMap() + let keys = [20, 10, 30, 5, 15, 25, 35] + for key in keys { + map[key] = "\(key)" + } + let sortedKeys = map.keys + XCTAssertEqual(sortedKeys, keys.sorted()) + + // Verify in-order traversal. + var previous: Int? = nil + for (key, value) in map { + if let prev = previous { + XCTAssertLessThanOrEqual(prev, key) + } + previous = key + XCTAssertEqual(value, "\(key)") + } + } + + func testRemoveAll() { + let map = TreeMap() + for i in 0..<100 { + map[i] = "\(i)" + } + XCTAssertEqual(map.count, 100) + map.removeAll() + XCTAssertEqual(map.count, 0) + XCTAssertTrue(map.isEmpty) + } + + func testBalancing() { + let map = TreeMap() + // Insert elements in ascending order to challenge the balancing. + for i in 1...1000 { + map[i] = i + } + // Verify in-order traversal produces sorted order. + var expected = 1 + for (key, value) in map { + XCTAssertEqual(key, expected) + XCTAssertEqual(value, expected) + expected += 1 + } + XCTAssertEqual(expected - 1, 1000) + + // Remove odd keys to force rebalancing. + for i in stride(from: 1, through: 1000, by: 2) { + _ = map.remove(key: i) + } + let expectedEvenKeys = (1...1000).filter { $0 % 2 == 0 } + XCTAssertEqual(map.keys, expectedEvenKeys) + } + + func testNonExistentDeletion() { + let map = TreeMap() + map[10] = "ten" + let removed = map.remove(key: 20) + XCTAssertNil(removed) + XCTAssertEqual(map.count, 1) + } + + func testDuplicateInsertion() { + let map = TreeMap() + map["a"] = "first" + XCTAssertEqual(map["a"], "first") + let oldValue = map.insert(key: "a", value: "second") + XCTAssertEqual(oldValue, "first") + XCTAssertEqual(map["a"], "second") + XCTAssertEqual(map.count, 1) + } +} From 6370105c85d69af56ac8e8ff80837f302871e0d6 Mon Sep 17 00:00:00 2001 From: Magesh K <47920326+mahee96@users.noreply.github.com> Date: Fri, 21 Feb 2025 19:06:13 +0530 Subject: [PATCH 05/25] - Feature: Implement Bulk add for Sources --- .../Sources/AddSourceViewController.swift | 417 ++++++++++++------ AltStore/Sources/Sources.storyboard | 9 +- 2 files changed, 294 insertions(+), 132 deletions(-) diff --git a/AltStore/Sources/AddSourceViewController.swift b/AltStore/Sources/AddSourceViewController.swift index 5e5c01dd..a13e8be0 100644 --- a/AltStore/Sources/AddSourceViewController.swift +++ b/AltStore/Sources/AddSourceViewController.swift @@ -43,10 +43,10 @@ extension AddSourceViewController var sourceAddress: String = "" @Published - var sourceURL: URL? + var sourceURLs: [URL] = [] @Published - var sourcePreviewResult: SourcePreviewResult? + var sourcePreviewResults: [SourcePreviewResult] = [] /* State */ @@ -60,6 +60,8 @@ extension AddSourceViewController class AddSourceViewController: UICollectionViewController { + private var stagedForAdd: [Source: Bool] = [:] + private lazy var dataSource = self.makeDataSource() private lazy var addSourceDataSource = self.makeAddSourceDataSource() private lazy var sourcePreviewDataSource = self.makeSourcePreviewDataSource() @@ -117,6 +119,7 @@ private extension AddSourceViewController layoutConfig.contentInsetsReference = .safeArea let layout = UICollectionViewCompositionalLayout(sectionProvider: { [weak self] (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in + guard let self, let section = Section(rawValue: sectionIndex) else { return nil } switch section { @@ -140,14 +143,19 @@ private extension AddSourceViewController configuration.showsSeparators = false configuration.backgroundColor = .clear - if self.viewModel.sourceURL != nil && self.viewModel.isShowingPreviewStatus + if !self.viewModel.sourceURLs.isEmpty && self.viewModel.isShowingPreviewStatus { - switch self.viewModel.sourcePreviewResult + for result in self.viewModel.sourcePreviewResults { - case (_, .success)?: configuration.footerMode = .none - case (_, .failure)?: configuration.footerMode = .supplementary - case nil where self.viewModel.isLoadingPreview: configuration.footerMode = .supplementary - default: configuration.footerMode = .none + switch result + { + case (_, .success): configuration.footerMode = .none + case (_, .failure): configuration.footerMode = .supplementary + break +// case nil where self.viewModel.isLoadingPreview: configuration.footerMode = .supplementary +// break +// default: configuration.footerMode = .none + } } } else @@ -303,50 +311,58 @@ private extension AddSourceViewController { /* Pipeline */ - // Map UITextField text -> URL + // Map UITextField text -> URLs self.viewModel.$sourceAddress - .map { [weak self] in self?.sourceURL(from: $0) } - .assign(to: &self.viewModel.$sourceURL) - + .map { [weak self] in + guard let self else { return [] } + print("\n\nStarting pipeline processing...\n\n") + + let lines = $0.split(whereSeparator: { $0.isWhitespace }).map(String.init).compactMap(self.sourceURL) + return lines + } + .assign(to: &self.viewModel.$sourceURLs) + let showPreviewStatusPublisher = self.viewModel.$isShowingPreviewStatus .filter { $0 == true } - let sourceURLPublisher = self.viewModel.$sourceURL + let sourceURLsPublisher = self.viewModel.$sourceURLs .removeDuplicates() .debounce(for: 0.2, scheduler: RunLoop.main) .receive(on: RunLoop.main) - .map { [weak self] sourceURL in + .map { [weak self] sourceURLs in // Only set sourcePreviewResult to nil if sourceURL actually changes. - self?.viewModel.sourcePreviewResult = nil - return sourceURL + self?.viewModel.sourcePreviewResults = [] + return sourceURLs } // Map URL -> Source Preview - Publishers.CombineLatest(sourceURLPublisher, showPreviewStatusPublisher.prepend(false)) + Publishers.CombineLatest(sourceURLsPublisher, showPreviewStatusPublisher.prepend(false)) .receive(on: RunLoop.main) .map { $0.0 } - .compactMap { [weak self] (sourceURL: URL?) -> AnyPublisher? in - guard let self else { return nil } - - guard let sourceURL else { - // Unlike above guard, this continues the pipeline with nil value. - return Just(nil).eraseToAnyPublisher() - } + .flatMap { [weak self] (sourceURLs: [URL]) -> AnyPublisher<[SourcePreviewResult?], Never> in + guard let self else { return Just([]).eraseToAnyPublisher() } self.viewModel.isLoadingPreview = true - return self.fetchSourcePreview(sourceURL: sourceURL).eraseToAnyPublisher() + + let publishers = sourceURLs.map { sourceURL in + print("Creating preview for source:", sourceURL, " ...") + return self.fetchSourcePreview(sourceURL: sourceURL) + .eraseToAnyPublisher() + } + + return publishers.isEmpty + ? Just([]).eraseToAnyPublisher() + : Publishers.MergeMany(publishers) + .collect() + .eraseToAnyPublisher() } - .switchToLatest() // Cancels previous publisher - .receive(on: RunLoop.main) - .sink { [weak self] sourcePreviewResult in + .sink { [weak self] sourcePreviewResults in self?.viewModel.isLoadingPreview = false - self?.viewModel.sourcePreviewResult = sourcePreviewResult + self?.viewModel.sourcePreviewResults = sourcePreviewResults.compactMap{$0} } .store(in: &self.cancellables) - /* Update UI */ - Publishers.CombineLatest(self.viewModel.$isLoadingPreview.removeDuplicates(), self.viewModel.$isShowingPreviewStatus.removeDuplicates()) .sink { [weak self] _ in @@ -359,7 +375,7 @@ private extension AddSourceViewController if let footerView = self.collectionView.supplementaryView(forElementKind: UICollectionView.elementKindSectionFooter, at: indexPath) as? PlaceholderCollectionReusableView { - self.configure(footerView, with: self.viewModel.sourcePreviewResult) + self.configure(footerView, with: self.viewModel.sourcePreviewResults) } let context = UICollectionViewLayoutInvalidationContext() @@ -370,27 +386,67 @@ private extension AddSourceViewController } .store(in: &self.cancellables) - self.viewModel.$sourcePreviewResult - .map { $0?.1 } - .map { result -> Managed? in - switch result - { - case .success(let source): return source - case .failure, nil: return nil +// self.viewModel.$sourcePreviewResults +// .map { sourcePreviewResults -> [Source] in +// // Process the full array: +// // - For each tuple, extract the `result` (the second element) +// // - For each result, convert it to a Managed if it's successful +// // - Remove any nil values from failed results +// let managedSources = sourcePreviewResults.compactMap { previewResult -> Managed? in +// switch previewResult.result { +// case .success(let source): +// return source +// case .failure: +// return nil +// } +// } +// // Optionally, remove duplicates based on identifier: +// // (This groups by identifier and keeps the first occurrence.) +// let uniqueManagedSources = Dictionary(grouping: managedSources, by: { $0.identifier }) +// .compactMap { $0.value.first } +// +// // Unwrap Managed into Source (assuming Managed has a wrappedValue property) +// let sources = uniqueManagedSources.map { $0.wrappedValue } +// return sources +// } +// .receive(on: RunLoop.main) +// .sink { [weak self] sources in +// self?.updateSourcesPreview(for: sources) +// } +// .store(in: &self.cancellables) + + self.viewModel.$sourcePreviewResults + .map { sourcePreviewResults -> [Source] in + var seenIdentifiers = Set() + let orderedSources = sourcePreviewResults.compactMap { previewResult -> Source? in + switch previewResult.result { + case .success(let managedSource): + let id = managedSource.identifier + guard !seenIdentifiers.contains(id) else { return nil } + seenIdentifiers.insert(id) + return managedSource.wrappedValue + case .failure: + return nil + } } - } - .removeDuplicates { (sourceA: Managed?, sourceB: Managed?) in - sourceA?.identifier == sourceB?.identifier + return orderedSources } .receive(on: RunLoop.main) - .sink { [weak self] source in - self?.updateSourcePreview(for: source?.wrappedValue) + .sink { [weak self] sources in + self?.updateSourcesPreview(for: sources) } .store(in: &self.cancellables) + - let addPublisher = NotificationCenter.default.publisher(for: AppManager.didAddSourceNotification) - let removePublisher = NotificationCenter.default.publisher(for: AppManager.didRemoveSourceNotification) - Publishers.Merge(addPublisher, removePublisher) + let mergedNotificationPublisher = Publishers.Merge( + NotificationCenter.default.publisher(for: AppManager.didAddSourceNotification), + NotificationCenter.default.publisher(for: AppManager.didRemoveSourceNotification) + ) + .receive(on: RunLoop.main) + .share() // Shares the upstream publisher with multiple subscribers + + // Update recommended sources section when sources are added/removed + mergedNotificationPublisher .compactMap { notification -> String? in guard let source = notification.object as? Source, let context = source.managedObjectContext @@ -399,7 +455,6 @@ private extension AddSourceViewController let sourceID = context.performAndWait { source.identifier } return sourceID } - .receive(on: RunLoop.main) .compactMap { [dataSource = recommendedSourcesDataSource] sourceID -> IndexPath? in guard let index = dataSource.items.firstIndex(where: { $0.identifier == sourceID }) else { return nil } @@ -411,6 +466,32 @@ private extension AddSourceViewController self?.collectionView.reloadItems(at: [indexPath]) } .store(in: &self.cancellables) + + // Update previews section when sources are added/removed +// mergedNotificationPublisher +// .sink { [weak self] _ in +// // reload the entire of previews section to get latest state +// self?.collectionView.reloadSections(IndexSet(integer: Section.preview.rawValue)) +// } +// .store(in: &self.cancellables) + + mergedNotificationPublisher + .compactMap { notification -> String? in + guard let source = notification.object as? Source, + let context = source.managedObjectContext + else { return nil } + return context.performAndWait { source.identifier } + } + .compactMap { [weak self] sourceID -> IndexPath? in + guard let dataSource = self?.sourcePreviewDataSource, + let index = dataSource.items.firstIndex(where: { $0.identifier == sourceID }) + else { return nil } + return IndexPath(item: index, section: Section.preview.rawValue) + } + .sink { [weak self] indexPath in + self?.collectionView.reloadItems(at: [indexPath]) + } + .store(in: &self.cancellables) } func sourceURL(from address: String) -> URL? @@ -458,35 +539,51 @@ private extension AddSourceViewController }) } - func updateSourcePreview(for source: Source?) - { - let items = [source].compactMap { $0 } + func updateSourcesPreview(for sources: [Source]) { + // Calculate changes needed to go from current items to new items + let currentItemCount = self.sourcePreviewDataSource.items.count + let newItemCount = sources.count - // Have to provide changes in terms of sourcePreviewDataSource. - let indexPath = IndexPath(row: 0, section: 0) + var changes: [RSTCellContentChange] = [] - if !items.isEmpty && self.sourcePreviewDataSource.items.isEmpty - { - let change = RSTCellContentChange(type: .insert, currentIndexPath: nil, destinationIndexPath: indexPath) - self.sourcePreviewDataSource.setItems(items, with: [change]) - } - else if items.isEmpty && !self.sourcePreviewDataSource.items.isEmpty - { - let change = RSTCellContentChange(type: .delete, currentIndexPath: indexPath, destinationIndexPath: nil) - self.sourcePreviewDataSource.setItems(items, with: [change]) - } - else if !items.isEmpty && !self.sourcePreviewDataSource.items.isEmpty - { - let change = RSTCellContentChange(type: .update, currentIndexPath: indexPath, destinationIndexPath: indexPath) - self.sourcePreviewDataSource.setItems(items, with: [change]) + if currentItemCount == 0 && newItemCount > 0 { + // Insert all items if we currently have none + for i in 0.. 0 && newItemCount == 0 { + // Delete all items if we're going to have none + for i in 0..(priority: .userInitiated) { [weak cell] in + guard let cell else { return } + + var isSourceAlreadyPersisted = false + do + { + isSourceAlreadyPersisted = try await source.isAdded + } + catch + { + print("Failed to determine if source is added.", error) + } + + // use the plus icon by default + var buttonIcon = UIImage(systemName: "plus.circle.fill", withConfiguration: config)?.withTintColor(.white, renderingMode: .alwaysOriginal) + + // if the source is already added/staged for adding, use the checkmark icon + let isStagedForAdd = self.stagedForAdd[source] == true + if isStagedForAdd || isSourceAlreadyPersisted + { + buttonIcon = UIImage(systemName: "checkmark.circle.fill", withConfiguration: config)? + .withTintColor(isSourceAlreadyPersisted ? .green : .white, renderingMode: .alwaysOriginal) + } + cell.bannerView.button.setImage(buttonIcon, for: .normal) + cell.bannerView.button.isEnabled = !isSourceAlreadyPersisted + } + } + + // set the icon + setButtonIcon() + let action = UIAction(identifier: .addSource) { [weak self] _ in - self?.add(source) + guard let self else { return } + + self.stagedForAdd[source, default: false].toggle() + + // update the button icon + setButtonIcon() } cell.bannerView.button.addAction(action, for: .primaryActionTriggered) - - Task(priority: .userInitiated) { - do - { - let isAdded = try await source.isAdded - if isAdded - { - cell.bannerView.button.isHidden = true - } - } - catch - { - print("Failed to determine if source is added.", error) - } - } } - func configure(_ footerView: PlaceholderCollectionReusableView, with sourcePreviewResult: SourcePreviewResult?) + func configure(_ footerView: PlaceholderCollectionReusableView, with sourcePreviewResults: [SourcePreviewResult?]) { footerView.placeholderView.stackView.isLayoutMarginsRelativeArrangement = false @@ -552,23 +669,33 @@ private extension AddSourceViewController footerView.placeholderView.detailTextLabel.isHidden = true - switch sourcePreviewResult + var errorText: String? = nil + var isError: Bool = false + for result in sourcePreviewResults { - case (let sourceURL, .failure(let previewError))? where self.viewModel.sourceURL == sourceURL && !self.viewModel.isLoadingPreview: - // The current URL matches the error being displayed, and we're not loading another preview, so show error. - - footerView.placeholderView.textLabel.text = (previewError as NSError).localizedDebugDescription ?? previewError.localizedDescription - footerView.placeholderView.textLabel.isHidden = false - - footerView.placeholderView.activityIndicatorView.stopAnimating() - - default: - // The current URL does not match the URL of the source/error being displayed, so show loading indicator. - - footerView.placeholderView.textLabel.text = nil - footerView.placeholderView.textLabel.isHidden = true - + switch result + { + case (let sourceURL, .failure(let previewError))? where (self.viewModel.sourceURLs.contains(sourceURL) && !self.viewModel.isLoadingPreview): + // The current URL matches the error being displayed, and we're not loading another preview, so show error. + + errorText = (previewError as NSError).localizedDebugDescription ?? previewError.localizedDescription + footerView.placeholderView.textLabel.text = errorText + footerView.placeholderView.textLabel.isHidden = false + + isError = true + + default: + // The current URL does not match the URL of the source/error being displayed, so show loading indicator. + errorText = nil + footerView.placeholderView.textLabel.isHidden = true + } + } + footerView.placeholderView.textLabel.text = errorText + + if !isError{ footerView.placeholderView.activityIndicatorView.startAnimating() + } else{ + footerView.placeholderView.activityIndicatorView.stopAnimating() } } @@ -652,30 +779,60 @@ private extension AddSourceViewController } } - func add(@AsyncManaged _ source: Source) + @IBAction func commitChanges(_ sender: UIBarButtonItem) { - Task { - do - { - let isRecommended = await $source.isRecommended - if isRecommended - { - try await AppManager.shared.add(source, message: nil, presentingViewController: self) - } - else - { - // Use default message - try await AppManager.shared.add(source, presentingViewController: self) - } - - self.dismiss() - + struct StagedSource: Hashable { + @AsyncManaged var source: Source + + // Conformance for Equatable/Hashable by comparing the underlying source + static func == (lhs: StagedSource, rhs: StagedSource) -> Bool { + return lhs.source.identifier == rhs.source.identifier } - catch is CancellationError {} - catch - { - let errorTitle = NSLocalizedString("Unable to Add Source", comment: "") - await self.presentAlert(title: errorTitle, message: error.localizedDescription) + + func hash(into hasher: inout Hasher) { + hasher.combine(source) + } + } + + Task { + var isCancelled = false + // OK: COMMIT the staged changes now + // Convert the stagedForAdd dictionary into an array of StagedSource + let stagedSources: [StagedSource] = self.stagedForAdd.filter { $0.value } + .map { StagedSource(source: $0.key) } + + for staged in stagedSources { + do + { + // Use the projected value to safely access isRecommended asynchronously + let isRecommended = await staged.$source.isRecommended + if isRecommended + { + try await AppManager.shared.add(staged.source, message: nil, presentingViewController: self) + } + else + { + // Use default message + try await AppManager.shared.add(staged.source, presentingViewController: self) + } + + // remove this kv pair + self.stagedForAdd.removeValue(forKey: staged.source) + } + catch is CancellationError { + isCancelled = true + break + } + catch + { + let errorTitle = NSLocalizedString("Unable to Add Source", comment: "") + await self.presentAlert(title: errorTitle, message: error.localizedDescription) + } + } + + if !isCancelled { + // finally dismiss the sheet/viewcontroller + self.dismiss() } } } @@ -737,7 +894,7 @@ extension AddSourceViewController: UICollectionViewDelegateFlowLayout case (.preview, UICollectionView.elementKindSectionFooter): let footerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: ReuseID.placeholderFooter.rawValue, for: indexPath) as! PlaceholderCollectionReusableView - self.configure(footerView, with: self.viewModel.sourcePreviewResult) + self.configure(footerView, with: self.viewModel.sourcePreviewResults) return footerView diff --git a/AltStore/Sources/Sources.storyboard b/AltStore/Sources/Sources.storyboard index 35cc8703..d521df37 100644 --- a/AltStore/Sources/Sources.storyboard +++ b/AltStore/Sources/Sources.storyboard @@ -1,9 +1,9 @@ - + - + @@ -224,6 +224,11 @@ + + + + + From 71212130c54d2b378f6a0cb12837e761eac409e6 Mon Sep 17 00:00:00 2001 From: Magesh K <47920326+mahee96@users.noreply.github.com> Date: Fri, 21 Feb 2025 19:08:42 +0530 Subject: [PATCH 06/25] - Bug-Fix: Use LinkedHashMap instead of swift standard dict which preserves insertion order --- AltStore/Sources/AddSourceViewController.swift | 4 ++-- SideStore/Tests/SideStoreTests.xctestplan | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/AltStore/Sources/AddSourceViewController.swift b/AltStore/Sources/AddSourceViewController.swift index a13e8be0..69939b81 100644 --- a/AltStore/Sources/AddSourceViewController.swift +++ b/AltStore/Sources/AddSourceViewController.swift @@ -60,7 +60,7 @@ extension AddSourceViewController class AddSourceViewController: UICollectionViewController { - private var stagedForAdd: [Source: Bool] = [:] + private var stagedForAdd: LinkedHashMap = LinkedHashMap() private lazy var dataSource = self.makeDataSource() private lazy var addSourceDataSource = self.makeAddSourceDataSource() @@ -817,7 +817,7 @@ private extension AddSourceViewController } // remove this kv pair - self.stagedForAdd.removeValue(forKey: staged.source) + _ = self.stagedForAdd.removeValue(forKey: staged.source) } catch is CancellationError { isCancelled = true diff --git a/SideStore/Tests/SideStoreTests.xctestplan b/SideStore/Tests/SideStoreTests.xctestplan index 1b37c370..d0177c64 100644 --- a/SideStore/Tests/SideStoreTests.xctestplan +++ b/SideStore/Tests/SideStoreTests.xctestplan @@ -18,8 +18,6 @@ "testTargets" : [ { "skippedTests" : [ - "UITests", - "UITests\/testBulkAddRecommendedSources()", "UITests\/testLaunchPerformance()", "UITestsLaunchTests", "UITestsLaunchTests\/testLaunch()" From 87fe36092776f16bc8f5d8a5ed41499f9986d965 Mon Sep 17 00:00:00 2001 From: Magesh K <47920326+mahee96@users.noreply.github.com> Date: Fri, 21 Feb 2025 19:24:40 +0530 Subject: [PATCH 07/25] - UnitTests: Moved DS unit tests into their own target --- AltStore.xcodeproj/project.pbxproj | 134 ++++++++++++++++-- .../xcschemes/DataStructuresTests.xcscheme | 60 ++++++++ SideStore/Tests/DataStructureTests.xctestplan | 35 +++++ SideStore/Tests/UITests/UITests.swift | 18 +-- .../datastructures/DataStructuresTests.swift | 17 +++ 5 files changed, 247 insertions(+), 17 deletions(-) create mode 100644 AltStore.xcodeproj/xcshareddata/xcschemes/DataStructuresTests.xcscheme create mode 100644 SideStore/Tests/DataStructureTests.xctestplan create mode 100644 SideStore/Tests/UnitTests/datastructures/DataStructuresTests.swift diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index accb2efe..84cd6a2e 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -59,13 +59,16 @@ A80D60D32D3DD85100CEF65D /* ReleaseTrack.swift in Sources */ = {isa = PBXBuildFile; fileRef = A80D60D12D3D705F00CEF65D /* ReleaseTrack.swift */; }; A80D790D2D2F20AF00A40F40 /* PaginationIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A80D790C2D2F20AF00A40F40 /* PaginationIntent.swift */; }; A80D790F2D2F217000A40F40 /* PaginationDataHolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A80D790E2D2F217000A40F40 /* PaginationDataHolder.swift */; }; - A81A8CB52D68B2180086C96F /* TreeMapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A81A8CB42D68B2180086C96F /* TreeMapTests.swift */; }; A81A8CB92D68B30B0086C96F /* SingletonGenericMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A868CFE32D319988002F1201 /* SingletonGenericMap.swift */; }; A81A8CBA2D68B3110086C96F /* TreeMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A81A8CB02D68B0320086C96F /* TreeMap.swift */; }; - A81A8CBB2D68B3230086C96F /* TreeMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A81A8CB02D68B0320086C96F /* TreeMap.swift */; }; A81A8CBD2D68B43F0086C96F /* LinkedHashMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A81A8CBC2D68B43F0086C96F /* LinkedHashMap.swift */; }; - A81A8CBE2D68B43F0086C96F /* LinkedHashMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A81A8CBC2D68B43F0086C96F /* LinkedHashMap.swift */; }; - A81A8CC02D68B4520086C96F /* LinkedHashMapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A81A8CBF2D68B4520086C96F /* LinkedHashMapTests.swift */; }; + A81A8CC82D68BA610086C96F /* DataStructuresTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A81A8CC72D68BA610086C96F /* DataStructuresTests.swift */; }; + A81A8CCE2D68BA8D0086C96F /* LinkedHashMapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A81A8CBF2D68B4520086C96F /* LinkedHashMapTests.swift */; }; + A81A8CCF2D68BA8D0086C96F /* TreeMapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A81A8CB42D68B2180086C96F /* TreeMapTests.swift */; }; + A81A8CD02D68BA9B0086C96F /* LinkedHashMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A81A8CBC2D68B43F0086C96F /* LinkedHashMap.swift */; }; + A81A8CD12D68BA9B0086C96F /* TreeMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A81A8CB02D68B0320086C96F /* TreeMap.swift */; }; + A81A8CD22D68BAA30086C96F /* SingletonGenericMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A868CFE32D319988002F1201 /* SingletonGenericMap.swift */; }; + A81A8CD42D68BAFF0086C96F /* DataStructureTests.xctestplan in Resources */ = {isa = PBXBuildFile; fileRef = A81A8CD32D68BAFF0086C96F /* DataStructureTests.xctestplan */; }; A82067842D03DC0600645C0D /* OperatingSystemVersion+Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5708416292448DA00D42D34 /* OperatingSystemVersion+Comparable.swift */; }; A82067C42D03E0DE00645C0D /* SemanticVersion in Frameworks */ = {isa = PBXBuildFile; productRef = A82067C32D03E0DE00645C0D /* SemanticVersion */; }; A859ED5C2D1EE827003DCC58 /* OpenSSL.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A859ED5B2D1EE80D003DCC58 /* OpenSSL.xcframework */; }; @@ -660,6 +663,9 @@ A81A8CB42D68B2180086C96F /* TreeMapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TreeMapTests.swift; sourceTree = ""; }; A81A8CBC2D68B43F0086C96F /* LinkedHashMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkedHashMap.swift; sourceTree = ""; }; A81A8CBF2D68B4520086C96F /* LinkedHashMapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkedHashMapTests.swift; sourceTree = ""; }; + A81A8CC52D68BA610086C96F /* DataStructureTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DataStructureTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + A81A8CC72D68BA610086C96F /* DataStructuresTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStructuresTests.swift; sourceTree = ""; }; + A81A8CD32D68BAFF0086C96F /* DataStructureTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = DataStructureTests.xctestplan; sourceTree = ""; }; A859ED5B2D1EE80D003DCC58 /* OpenSSL.xcframework */ = {isa = PBXFileReference; expectedSignature = "AppleDeveloperProgram:67RAULRX93:Marcin Krzyzanowski"; lastKnownFileType = wrapper.xcframework; name = OpenSSL.xcframework; path = SideStore/AltSign/Dependencies/OpenSSL/Frameworks/OpenSSL.xcframework; sourceTree = ""; }; A85ACB8E2D1F31C400AA3DE7 /* AltBackup.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AltBackup.xcconfig; sourceTree = ""; }; A85ACB8F2D1F31C400AA3DE7 /* AltStore.debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AltStore.debug.xcconfig; sourceTree = ""; }; @@ -1091,6 +1097,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A81A8CC22D68BA610086C96F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; A8E2DB1E2D684CBD009E5D31 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -1250,6 +1263,7 @@ children = ( A81A8CBF2D68B4520086C96F /* LinkedHashMapTests.swift */, A81A8CB42D68B2180086C96F /* TreeMapTests.swift */, + A81A8CC72D68BA610086C96F /* DataStructuresTests.swift */, ); path = datastructures; sourceTree = ""; @@ -1389,6 +1403,7 @@ A81A8CB22D68B2030086C96F /* UnitTests */, A8E2DB302D684E2A009E5D31 /* UITests */, A8E2DB332D68507F009E5D31 /* SideStoreTests.xctestplan */, + A81A8CD32D68BAFF0086C96F /* DataStructureTests.xctestplan */, ); path = Tests; sourceTree = ""; @@ -1992,6 +2007,7 @@ 191E5FAB290A5D92001A3B7C /* libminimuxer.a */, D586D39828EF58B0000E101F /* AltTests.xctest */, A8E2DB212D684CBD009E5D31 /* UITests.xctest */, + A81A8CC52D68BA610086C96F /* DataStructureTests.xctest */, ); name = Products; sourceTree = ""; @@ -2472,6 +2488,25 @@ productReference = 191E5FAB290A5D92001A3B7C /* libminimuxer.a */; productType = "com.apple.product-type.library.static"; }; + A81A8CC42D68BA610086C96F /* DataStructureTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = A81A8CC92D68BA610086C96F /* Build configuration list for PBXNativeTarget "DataStructureTests" */; + buildPhases = ( + A81A8CC12D68BA610086C96F /* Sources */, + A81A8CC22D68BA610086C96F /* Frameworks */, + A81A8CC32D68BA610086C96F /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = DataStructureTests; + packageProductDependencies = ( + ); + productName = DataStructuresTests; + productReference = A81A8CC52D68BA610086C96F /* DataStructureTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; A8E2DB202D684CBD009E5D31 /* UITests */ = { isa = PBXNativeTarget; buildConfigurationList = A8E2DB292D684CBD009E5D31 /* Build configuration list for PBXNativeTarget "UITests" */; @@ -2613,6 +2648,9 @@ 191E5FAA290A5D92001A3B7C = { CreatedOnToolsVersion = 14.0; }; + A81A8CC42D68BA610086C96F = { + CreatedOnToolsVersion = 16.2; + }; A8E2DB202D684CBD009E5D31 = { CreatedOnToolsVersion = 16.2; TestTargetID = BFD247692284B9A500981D42; @@ -2684,6 +2722,7 @@ 19104DB12909C06C00C49C7B /* EmotionalDamage */, 191E5FAA290A5D92001A3B7C /* minimuxer */, A8E2DB202D684CBD009E5D31 /* UITests */, + A81A8CC42D68BA610086C96F /* DataStructureTests */, ); }; /* End PBXProject section */ @@ -2741,10 +2780,18 @@ /* End PBXReferenceProxy section */ /* Begin PBXResourcesBuildPhase section */ + A81A8CC32D68BA610086C96F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; A8E2DB1F2D684CBD009E5D31 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + A81A8CD42D68BAFF0086C96F /* DataStructureTests.xctestplan in Resources */, A8E2DB342D68507F009E5D31 /* SideStoreTests.xctestplan in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -2920,16 +2967,25 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A81A8CC12D68BA610086C96F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A81A8CC82D68BA610086C96F /* DataStructuresTests.swift in Sources */, + A81A8CD02D68BA9B0086C96F /* LinkedHashMap.swift in Sources */, + A81A8CD22D68BAA30086C96F /* SingletonGenericMap.swift in Sources */, + A81A8CD12D68BA9B0086C96F /* TreeMap.swift in Sources */, + A81A8CCE2D68BA8D0086C96F /* LinkedHashMapTests.swift in Sources */, + A81A8CCF2D68BA8D0086C96F /* TreeMapTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; A8E2DB1D2D684CBD009E5D31 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( A8E2DB312D684E2A009E5D31 /* UITests.swift in Sources */, - A81A8CB52D68B2180086C96F /* TreeMapTests.swift in Sources */, - A81A8CBB2D68B3230086C96F /* TreeMap.swift in Sources */, A8E2DB322D684E2A009E5D31 /* UITestsLaunchTests.swift in Sources */, - A81A8CC02D68B4520086C96F /* LinkedHashMapTests.swift in Sources */, - A81A8CBE2D68B43F0086C96F /* LinkedHashMap.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3491,6 +3547,59 @@ }; name = Release; }; + A81A8CCA2D68BA610086C96F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.SideStore.SideStore.DataStructuresTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + A81A8CCB2D68BA610086C96F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.SideStore.SideStore.DataStructuresTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; A8E2DB2A2D684CBD009E5D31 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = A8E2DB2C2D684D39009E5D31 /* UITests.xcconfig */; @@ -4079,6 +4188,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + A81A8CC92D68BA610086C96F /* Build configuration list for PBXNativeTarget "DataStructureTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A81A8CCA2D68BA610086C96F /* Debug */, + A81A8CCB2D68BA610086C96F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; A8E2DB292D684CBD009E5D31 /* Build configuration list for PBXNativeTarget "UITests" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/AltStore.xcodeproj/xcshareddata/xcschemes/DataStructuresTests.xcscheme b/AltStore.xcodeproj/xcshareddata/xcschemes/DataStructuresTests.xcscheme new file mode 100644 index 00000000..e5c9bc34 --- /dev/null +++ b/AltStore.xcodeproj/xcshareddata/xcschemes/DataStructuresTests.xcscheme @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SideStore/Tests/DataStructureTests.xctestplan b/SideStore/Tests/DataStructureTests.xctestplan new file mode 100644 index 00000000..1ccf58e8 --- /dev/null +++ b/SideStore/Tests/DataStructureTests.xctestplan @@ -0,0 +1,35 @@ +{ + "configurations" : [ + { + "id" : "93E5E265-DC67-47F3-A214-8082A3421288", + "name" : "Test Scheme Action", + "options" : { + + } + } + ], + "defaultOptions" : { + "targetForVariableExpansion" : { + "containerPath" : "container:AltStore.xcodeproj", + "identifier" : "BFD247692284B9A500981D42", + "name" : "SideStore" + } + }, + "testTargets" : [ + { + "skippedTests" : { + "suites" : [ + { + "name" : "DataStructuresTests" + } + ] + }, + "target" : { + "containerPath" : "container:AltStore.xcodeproj", + "identifier" : "A81A8CC42D68BA610086C96F", + "name" : "DataStructureTests" + } + } + ], + "version" : 2 +} diff --git a/SideStore/Tests/UITests/UITests.swift b/SideStore/Tests/UITests/UITests.swift index dbbe73bd..39e535a8 100644 --- a/SideStore/Tests/UITests/UITests.swift +++ b/SideStore/Tests/UITests/UITests.swift @@ -116,15 +116,15 @@ private extension UITests { let recommendedSources: [(identifier: String, alertTitle: String, requiresSwipe: Bool)] = [ ("SideStore Team Picks\ncommunity-apps.sidestore.io/sidecommunity.json", "SideStore Team Picks", false), ("Provenance EMU\nprovenance-emu.com/apps.json", "Provenance EMU", false), -// ("Countdown Respository\nneoarz.github.io/Countdown-App/Countdown.json", "Countdown Respository", false), -// ("OatmealDome's AltStore Source\naltstore.oatmealdome.me", "OatmealDome's AltStore Source", false), -// ("UTM Repository\nVirtual machines for iOS", "UTM Repository", true), -// ("Flyinghead\nflyinghead.github.io/flycast-builds/altstore.json", "Flyinghead", false), -// ("PojavLauncher Repository\nalt.crystall1ne.dev", "PojavLauncher Repository", true), -// ("PokeMMO\npokemmo.eu/altstore/", "PokeMMO", false), -// ("Odyssey\ntheodyssey.dev/altstore/odysseysource.json", "Odyssey", false), -// ("Yattee\nrepos.yattee.stream/alt/apps.json", "Yattee", false), -// ("ThatStella7922 Source\nThe home for all apps ThatStella7922", "ThatStella7922 Source", false) + ("Countdown Respository\nneoarz.github.io/Countdown-App/Countdown.json", "Countdown Respository", false), + ("OatmealDome's AltStore Source\naltstore.oatmealdome.me", "OatmealDome's AltStore Source", false), + ("UTM Repository\nVirtual machines for iOS", "UTM Repository", true), + ("Flyinghead\nflyinghead.github.io/flycast-builds/altstore.json", "Flyinghead", false), + ("PojavLauncher Repository\nalt.crystall1ne.dev", "PojavLauncher Repository", true), + ("PokeMMO\npokemmo.eu/altstore/", "PokeMMO", false), + ("Odyssey\ntheodyssey.dev/altstore/odysseysource.json", "Odyssey", false), + ("Yattee\nrepos.yattee.stream/alt/apps.json", "Yattee", false), + ("ThatStella7922 Source\nThe home for all apps ThatStella7922", "ThatStella7922 Source", false) ] // Tap on each recommended source's "add" button. diff --git a/SideStore/Tests/UnitTests/datastructures/DataStructuresTests.swift b/SideStore/Tests/UnitTests/datastructures/DataStructuresTests.swift new file mode 100644 index 00000000..3f19afba --- /dev/null +++ b/SideStore/Tests/UnitTests/datastructures/DataStructuresTests.swift @@ -0,0 +1,17 @@ +// +// DataStructuresTests.swift +// DataStructuresTests +// +// Created by Magesh K on 21/02/25. +// Copyright © 2025 SideStore. All rights reserved. +// + +import Testing + +struct DataStructuresTests { + + @Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. + } + +} From 4659d617f83c1d57a0da15d93d0d4746c6479b65 Mon Sep 17 00:00:00 2001 From: Magesh K <47920326+mahee96@users.noreply.github.com> Date: Sat, 22 Feb 2025 00:25:49 +0530 Subject: [PATCH 08/25] - Bug-Fix: bulk added source previews were shown out of order --- .../Sources/AddSourceViewController.swift | 77 ++++----- SideStore/Tests/UITests/UITests.swift | 161 ++++++++++++++---- 2 files changed, 164 insertions(+), 74 deletions(-) diff --git a/AltStore/Sources/AddSourceViewController.swift b/AltStore/Sources/AddSourceViewController.swift index 69939b81..1f756cdb 100644 --- a/AltStore/Sources/AddSourceViewController.swift +++ b/AltStore/Sources/AddSourceViewController.swift @@ -315,10 +315,13 @@ private extension AddSourceViewController self.viewModel.$sourceAddress .map { [weak self] in guard let self else { return [] } - print("\n\nStarting pipeline processing...\n\n") - let lines = $0.split(whereSeparator: { $0.isWhitespace }).map(String.init).compactMap(self.sourceURL) - return lines + // Preserve order of parsed URLs + let lines = $0.split(whereSeparator: { $0.isWhitespace }) + .map(String.init) + .compactMap(self.sourceURL) + + return NSOrderedSet(array: lines).array as! [URL] // de-duplicate while preserving order } .assign(to: &self.viewModel.$sourceURLs) @@ -344,16 +347,26 @@ private extension AddSourceViewController self.viewModel.isLoadingPreview = true - let publishers = sourceURLs.map { sourceURL in - print("Creating preview for source:", sourceURL, " ...") - return self.fetchSourcePreview(sourceURL: sourceURL) + // Create publishers maintaining order + let publishers = sourceURLs.enumerated().map { index, sourceURL in + self.fetchSourcePreview(sourceURL: sourceURL) + .map { result in + // Add index to maintain order + (index: index, result: result) + } .eraseToAnyPublisher() } + // since network requests are concurrent, we sort the values when they are received return publishers.isEmpty ? Just([]).eraseToAnyPublisher() : Publishers.MergeMany(publishers) - .collect() + .collect() // await all publishers to emit the results + .map { results in // perform sorting of the collected results + // Sort by original index before returning + results.sorted { $0.index < $1.index } + .map { $0.result } + } .eraseToAnyPublisher() } .sink { [weak self] sourcePreviewResults in @@ -386,49 +399,20 @@ private extension AddSourceViewController } .store(in: &self.cancellables) -// self.viewModel.$sourcePreviewResults -// .map { sourcePreviewResults -> [Source] in -// // Process the full array: -// // - For each tuple, extract the `result` (the second element) -// // - For each result, convert it to a Managed if it's successful -// // - Remove any nil values from failed results -// let managedSources = sourcePreviewResults.compactMap { previewResult -> Managed? in -// switch previewResult.result { -// case .success(let source): -// return source -// case .failure: -// return nil -// } -// } -// // Optionally, remove duplicates based on identifier: -// // (This groups by identifier and keeps the first occurrence.) -// let uniqueManagedSources = Dictionary(grouping: managedSources, by: { $0.identifier }) -// .compactMap { $0.value.first } -// -// // Unwrap Managed into Source (assuming Managed has a wrappedValue property) -// let sources = uniqueManagedSources.map { $0.wrappedValue } -// return sources -// } -// .receive(on: RunLoop.main) -// .sink { [weak self] sources in -// self?.updateSourcesPreview(for: sources) -// } -// .store(in: &self.cancellables) - self.viewModel.$sourcePreviewResults .map { sourcePreviewResults -> [Source] in - var seenIdentifiers = Set() - let orderedSources = sourcePreviewResults.compactMap { previewResult -> Source? in - switch previewResult.result { - case .success(let managedSource): - let id = managedSource.identifier - guard !seenIdentifiers.contains(id) else { return nil } - seenIdentifiers.insert(id) - return managedSource.wrappedValue - case .failure: + // Maintain order based on original sourceURLs array + let orderedSources = self.viewModel.sourceURLs.compactMap { sourceURL -> Source? in + // Find the preview result matching this URL + guard let previewResult = sourcePreviewResults.first(where: { $0.sourceURL == sourceURL }), + case .success(let managedSource) = previewResult.result + else { return nil } + + return managedSource.wrappedValue } + return orderedSources } .receive(on: RunLoop.main) @@ -615,6 +599,9 @@ private extension AddSourceViewController cell.bannerView.button.tintColor = .clear cell.bannerView.button.isHidden = false + // mark the button with label (useful for accessibility and for UITests) + cell.bannerView.button.accessibilityIdentifier = "add" + func setButtonIcon() { Task(priority: .userInitiated) { [weak cell] in diff --git a/SideStore/Tests/UITests/UITests.swift b/SideStore/Tests/UITests/UITests.swift index 39e535a8..9137e6bf 100644 --- a/SideStore/Tests/UITests/UITests.swift +++ b/SideStore/Tests/UITests/UITests.swift @@ -35,6 +35,51 @@ final class UITests: XCTestCase { super.tearDown() } + +// @MainActor // Xcode 16.2 bug: UITest Record Button Disabled with @MainActor, see: https://stackoverflow.com/a/79445950/11971304 + func testBulkAddRecommendedSources() throws { + + let app = XCUIApplication() + app.launch() + + let systemAlert = Self.springboard_app.alerts["“\(Self.APP_NAME)” Would Like to Send You Notifications"] + + XCTAssertTrue(systemAlert.waitForExistence(timeout: 0.2), "Notifications alert did not appear") + systemAlert.scrollViews.otherElements.buttons["Allow"].tap() + + // Do the actual validation + try performBulkAddingRecommendedSources(for: app) + } + + func testBulkAddInputSources() throws { + + let app = XCUIApplication() + app.launch() + + let systemAlert = Self.springboard_app.alerts["“\(Self.APP_NAME)” Would Like to Send You Notifications"] + + XCTAssertTrue(systemAlert.waitForExistence(timeout: 0.2), "Notifications alert did not appear") + systemAlert.scrollViews.otherElements.buttons["Allow"].tap() + + // Do the actual validation + try performBulkAddingInputSources(for: app) + } + + +// @MainActor +// func testLaunchPerformance() throws { +// if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { +// // This measures how long it takes to launch your application. +// measure(metrics: [XCTApplicationLaunchMetric()]) { +// XCUIApplication().launch() +// } +// } +// } +} + +// Helpers +private extension UITests { + class func deleteMyApp() { XCUIApplication().terminate() dismissSpringboardAlerts() @@ -60,33 +105,7 @@ final class UITests: XCTestCase { springboard_app.tap() } -// @MainActor // Xcode 16.2 bug: UITest Record Button Disabled with @MainActor, see: https://stackoverflow.com/a/79445950/11971304 - func testBulkAddRecommendedSources() throws { - - let app = XCUIApplication() - app.launch() - - let systemAlert = Self.springboard_app.alerts["“\(Self.APP_NAME)” Would Like to Send You Notifications"] - - XCTAssertTrue(systemAlert.waitForExistence(timeout: 0.2), "Notifications alert did not appear") - systemAlert.scrollViews.otherElements.buttons["Allow"].tap() - - // Do the actual validation - try performBulkAddingRecommendedSources(for: app) - } - -// @MainActor -// func testLaunchPerformance() throws { -// if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { -// // This measures how long it takes to launch your application. -// measure(metrics: [XCTApplicationLaunchMetric()]) { -// XCUIApplication().launch() -// } -// } -// } -} - -private extension UITests { + class func dismissSpringboardAlerts() { for alert in springboard_app.alerts.allElementsBoundByIndex { if alert.exists { @@ -102,9 +121,75 @@ private extension UITests { } - +// Test guts (definition) private extension UITests { + private func performBulkAddingInputSources(for app: XCUIApplication) throws { + + // set content into clipboard (for bulk add (paste)) + // NOTE: THIS IS AN ORDERED SEQUENCE AND MUST MATCH THE ORDER in textInputSources BELOW (Remember to take this into account when adding more entries) + UIPasteboard.general.string = """ + https://alts.lao.sb + https://taurine.app/altstore/taurinestore.json + https://randomblock1.com/altstore/apps.json + https://burritosoftware.github.io/altstore/channels/burritosource.json + https://bit.ly/40Isul6 + https://bit.ly/wuxuslibraryplus + https://bit.ly/Quantumsource-plus + https://bit.ly/Altstore-complete + https://bit.ly/Quantumsource + """.trimmedIndentation + + let app = XCUIApplication() + app.tabBars["Tab Bar"].buttons["Sources"].tap() + app.navigationBars["Sources"].buttons["Add"].tap() + + let collectionViewsQuery = app.collectionViews + let appsSidestoreIoTextField = collectionViewsQuery.textFields["apps.sidestore.io"] + appsSidestoreIoTextField.tap() + appsSidestoreIoTextField.tap() + collectionViewsQuery.staticTexts["Paste"].tap() + + let cellsQuery = collectionViewsQuery.cells + + // Data model for recommended sources. NOTE: This list order is required to be the same as that of "Add Source" Screen + let textInputSources: [(identifier: String, alertTitle: String, requiresSwipe: Bool)] = [ + ("Laoalts\nalts.lao.sb", "Laoalts", false), + ("Taurine\ntaurine.app/altstore/taurinestore.json", "Taurine", false), + ("RandomSource\nrandomblock1.com/altstore/apps.json", "RandomSource", false), + ("Burrito's AltStore\nburritosoftware.github.io/altstore/channels/burritosource.json", "Burrito's AltStore", true), + ("Qn_'s AltStore Repo\nbit.ly/40Isul6", "Qn_'s AltStore Repo", false), + ("WuXu's Library++\nThe Most Up-To-Date IPA Library on AltStore.", "WuXu's Library++", false), + ("Quantum Source++\nContains tweaked apps, free streaming, cracked apps, and more.", "Quantum Source++", false), + ("AltStore Complete\nContains tweaked apps, free streaming, cracked apps, and more.", "AltStore Complete", false), + ("Quantum Source\nContains all of your favorite emulators, games, jailbreaks, utilities, and more.", "Quantum Source", false), + ] + + // Tap on each textInputSources source's "add" button. + for source in textInputSources { + let sourceButton = cellsQuery.otherElements + .containing(.button, identifier: source.identifier) + .children(matching: .button)[source.identifier] +// let addButton = sourceButton.children(matching: .button).firstMatch + let addButton = sourceButton.children(matching: .button)["add"] + addButton.tap() + if source.requiresSwipe { + sourceButton.swipeUp(velocity: .slow) // Swipe up if needed. + } + } + + // Commit the changes by tapping "Done". + app.navigationBars["Add Source"].buttons["Done"].tap() + + // Accept each source addition via alert. + for source in textInputSources { + let alertIdentifier = "Would you like to add the source “\(source.alertTitle)”?" + app.alerts[alertIdentifier] + .scrollViews.otherElements.buttons["Add Source"] + .tap() + } + } + private func performBulkAddingRecommendedSources(for app: XCUIApplication) throws { // Navigate to the Sources screen and open the Add Source view. app.tabBars["Tab Bar"].buttons["Sources"].tap() @@ -120,7 +205,7 @@ private extension UITests { ("OatmealDome's AltStore Source\naltstore.oatmealdome.me", "OatmealDome's AltStore Source", false), ("UTM Repository\nVirtual machines for iOS", "UTM Repository", true), ("Flyinghead\nflyinghead.github.io/flycast-builds/altstore.json", "Flyinghead", false), - ("PojavLauncher Repository\nalt.crystall1ne.dev", "PojavLauncher Repository", true), + ("PojavLauncher Repository\nalt.crystall1ne.dev", "PojavLauncher Repository", false), ("PokeMMO\npokemmo.eu/altstore/", "PokeMMO", false), ("Odyssey\ntheodyssey.dev/altstore/odysseysource.json", "Odyssey", false), ("Yattee\nrepos.yattee.stream/alt/apps.json", "Yattee", false), @@ -151,3 +236,21 @@ private extension UITests { } } } + + + + + +extension String { + var trimmedIndentation: String { + let lines = self.split(separator: "\n", omittingEmptySubsequences: false) + let minIndent = lines + .filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } // Ignore empty lines + .map { $0.prefix { $0.isWhitespace }.count } + .min() ?? 0 + + return lines.map { line in + String(line.dropFirst(minIndent)) + }.joined(separator: "\n") + } +} From e43bff5f8f02646e2d71a589b344abb4991f3445 Mon Sep 17 00:00:00 2001 From: Magesh K <47920326+mahee96@users.noreply.github.com> Date: Sat, 22 Feb 2025 01:44:24 +0530 Subject: [PATCH 09/25] - Makefile: Added test and clean targets, fixed override params bug when empty --- Makefile | 57 ++++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 45 insertions(+), 12 deletions(-) diff --git a/Makefile b/Makefile index 9247cd85..c847ba7b 100755 --- a/Makefile +++ b/Makefile @@ -167,22 +167,55 @@ test: BUILD_CONFIG ?= Release MARKETING_VERSION ?= BUNDLE_ID_SUFFIX ?= +# Common build settings for xcodebuild +COMMON_BUILD_SETTINGS = \ + -sdk iphoneos \ + -configuration $(BUILD_CONFIG) \ + CODE_SIGNING_REQUIRED=NO \ + AD_HOC_CODE_SIGNING_ALLOWED=YES \ + CODE_SIGNING_ALLOWED=NO \ + DEVELOPMENT_TEAM=XYZ0123456 \ + ORG_IDENTIFIER=com.SideStore + +# Append MARKETING_VERSION if it’s not empty (coz otherwise the blank entry becomes override) +ifneq ($(strip $(MARKETING_VERSION)),) +COMMON_BUILD_SETTINGS += MARKETING_VERSION=$(MARKETING_VERSION) +endif + +# Append BUNDLE_ID_SUFFIX if it’s not empty (coz otherwise the blank entry becomes override) +ifneq ($(strip $(BUNDLE_ID_SUFFIX)),) +COMMON_BUILD_SETTINGS += BUNDLE_ID_SUFFIX=$(BUNDLE_ID_SUFFIX) +endif + build: @echo ">>>>>>>>> BUILD_CONFIG is set to '$(BUILD_CONFIG)', Building for $(BUILD_CONFIG) mode! <<<<<<<<<<" @echo "" @xcodebuild -workspace AltStore.xcworkspace \ - -scheme SideStore \ - -sdk iphoneos \ - -configuration $(BUILD_CONFIG) \ - archive -archivePath ./SideStore \ - CODE_SIGNING_REQUIRED=NO \ - AD_HOC_CODE_SIGNING_ALLOWED=YES \ - CODE_SIGNING_ALLOWED=NO \ - DEVELOPMENT_TEAM=XYZ0123456 \ - ORG_IDENTIFIER=com.SideStore \ - MARKETING_VERSION=$(MARKETING_VERSION) \ - BUNDLE_ID_SUFFIX=$(BUNDLE_ID_SUFFIX) -# DWARF_DSYM_FOLDER_PATH="." + -scheme SideStore \ + archive -archivePath ./SideStore \ + $(COMMON_BUILD_SETTINGS) + +boot-sim: + @if xcrun simctl list devices "iPhone 16 Pro" | grep -q "Booted"; then \ + echo "Simulator 'iPhone 16 Pro' is already booted."; \ + else \ + echo "Booting simulator 'iPhone 16 Pro'..."; \ + xcrun simctl boot "iPhone 16 Pro"; \ + fi + +build-and-test: + @echo ">>>>>>>>> BUILD_CONFIG is set to '$(BUILD_CONFIG)', Building for $(BUILD_CONFIG) mode! <<<<<<<<<<" + @echo "" + @echo "Performing a build and running tests..." + @xcodebuild test -workspace AltStore.xcworkspace \ + -scheme SideStore \ + -destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=18.2' \ + -enableCodeCoverage YES \ + $(COMMON_BUILD_SETTINGS) + +clean-build: + @echo "Cleaning build artifacts..." + @xcodebuild clean -workspace AltStore.xcworkspace -scheme SideStore fakesign-apps: rm -rf SideStore.xcarchive/Products/Applications/SideStore.app/Frameworks/AltStoreCore.framework/Frameworks/ From 5323fdadcf4c233e5d9924fe5d183c5663266dff Mon Sep 17 00:00:00 2001 From: Magesh K <47920326+mahee96@users.noreply.github.com> Date: Sat, 22 Feb 2025 02:37:30 +0530 Subject: [PATCH 10/25] - CI: Included tests for CI builds --- .github/workflows/reusable-build-workflow.yml | 21 ++++++++++++++++--- .gitignore | 6 +++++- Makefile | 1 + 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/.github/workflows/reusable-build-workflow.yml b/.github/workflows/reusable-build-workflow.yml index 7e51fef3..6fcfaf0a 100644 --- a/.github/workflows/reusable-build-workflow.yml +++ b/.github/workflows/reusable-build-workflow.yml @@ -66,8 +66,10 @@ jobs: with: submodules: recursive - - name: Install dependencies - ldid & xcbeautify - run: brew install ldid xcbeautify + - name: Install dependencies - ldid & xcbeautify & xcpretty + run: | + brew install ldid xcbeautify + gem install xcpretty # for test reports - name: Set ref based on is_shared_build_num if: ${{ inputs.is_beta }} @@ -226,10 +228,17 @@ jobs: run: | echo "BUNDLE_ID_SUFFIX=${{ inputs.bundle_id_suffix }}" >> $GITHUB_ENV - - name: Build SideStore + - name: Build and run SideStore Tests + # using 'tee' to intercept stdout and log for detailed build-log + run: | + NSUnbufferedIO=YES make boot-sim build-and-test 2>&1 | tee build.log | xcpretty -r junit --output ./build/tests/test-results.xml && exit ${PIPESTATUS[0]} + echo "--------------------------------------------------------------------" >> build.log + + - name: Build SideStore archive # using 'tee' to intercept stdout and log for detailed build-log run: | NSUnbufferedIO=YES make build 2>&1 | tee build.log | xcbeautify --renderer github-actions && exit ${PIPESTATUS[0]} + echo "--------------------------------------------------------------------" >> build.log - name: Fakesign app run: make fakesign | tee -a build.log @@ -329,6 +338,12 @@ jobs: name: SideStore-${{ steps.version.outputs.version }}-dSYM path: ./SideStore.xcarchive/dSYMs/* + - name: Upload Test Artifact + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ steps.version.outputs.version }}.zip + path: ./build/tests/* + - name: Upload encrypted-build_log.zip uses: actions/upload-artifact@v4 with: diff --git a/.gitignore b/.gitignore index d7f580cd..0d7ee15c 100644 --- a/.gitignore +++ b/.gitignore @@ -63,4 +63,8 @@ SideStore/.skip-prebuilt-fetch-em_proxy # Never check-in this package.resolved file # coz SPM then resolves packages using the stale entries in this file *.xcodeproj/**/Package.resolved -*.xcworkspace/**/Package.resolved \ No newline at end of file +*.xcworkspace/**/Package.resolved + +# build/test artifacts +build.log +report.html diff --git a/Makefile b/Makefile index c847ba7b..2bbf3f72 100755 --- a/Makefile +++ b/Makefile @@ -211,6 +211,7 @@ build-and-test: -scheme SideStore \ -destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=18.2' \ -enableCodeCoverage YES \ + -resultBundlePath build/tests/test-results.xcresult \ $(COMMON_BUILD_SETTINGS) clean-build: From ca8c394ae002c20b9e0e18b2f1266c493c78672c Mon Sep 17 00:00:00 2001 From: Magesh K <47920326+mahee96@users.noreply.github.com> Date: Sat, 22 Feb 2025 17:12:12 +0530 Subject: [PATCH 11/25] - CI: Improve build log capture in case of errors --- .github/workflows/alpha.yml | 2 +- .github/workflows/nightly.yml | 2 +- .github/workflows/reusable-build-workflow.yml | 160 ++++++++++++------ .gitignore | 4 - Makefile | 15 +- 5 files changed, 122 insertions(+), 61 deletions(-) diff --git a/.github/workflows/alpha.yml b/.github/workflows/alpha.yml index f1813baa..5955c824 100644 --- a/.github/workflows/alpha.yml +++ b/.github/workflows/alpha.yml @@ -17,7 +17,7 @@ jobs: bundle_id: "com.SideStore.SideStore" # bundle_id_suffix: ".Alpha" is_beta: true - publish: true + publish: ${{ vars.PUBLISH_ALPHA_UPDATES == 'true' }} is_shared_build_num: false release_tag: "alpha" release_name: "Alpha" diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 967f6056..3f4c7952 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -70,7 +70,7 @@ jobs: bundle_id: "com.SideStore.SideStore" # bundle_id_suffix: ".Nightly" is_beta: true - publish: true + publish: ${{ vars.PUBLISH_NIGHTLY_UPDATES == 'true' }} is_shared_build_num: false release_tag: "nightly" release_name: "Nightly" diff --git a/.github/workflows/reusable-build-workflow.yml b/.github/workflows/reusable-build-workflow.yml index 6fcfaf0a..74fa6cb0 100644 --- a/.github/workflows/reusable-build-workflow.yml +++ b/.github/workflows/reusable-build-workflow.yml @@ -201,7 +201,14 @@ jobs: ./AltStore.xcworkspace/ key: pods-cache-${{ hashFiles('Podfile') }} + - name: Clean previous build artifacts + # using 'tee' to intercept stdout and log for detailed build-log + run: | + make clean + mkdir -p build/logs + - name: List Files and derived data + if: always() run: | echo ">>>>>>>>> Workdir <<<<<<<<<<" ls -la . @@ -223,30 +230,62 @@ jobs: ls -la ~/Library/Developer/Xcode/DerivedData || true # List contents if directory exists echo "" - - name: Set BundleID Suffix for Sidestore build run: | echo "BUNDLE_ID_SUFFIX=${{ inputs.bundle_id_suffix }}" >> $GITHUB_ENV - - name: Build and run SideStore Tests + + - name: Build SideStore.xcarchive # using 'tee' to intercept stdout and log for detailed build-log run: | - NSUnbufferedIO=YES make boot-sim build-and-test 2>&1 | tee build.log | xcpretty -r junit --output ./build/tests/test-results.xml && exit ${PIPESTATUS[0]} - echo "--------------------------------------------------------------------" >> build.log - - - name: Build SideStore archive - # using 'tee' to intercept stdout and log for detailed build-log - run: | - NSUnbufferedIO=YES make build 2>&1 | tee build.log | xcbeautify --renderer github-actions && exit ${PIPESTATUS[0]} - echo "--------------------------------------------------------------------" >> build.log + NSUnbufferedIO=YES make -B build 2>&1 | tee -a build/logs/build.log | xcbeautify --renderer github-actions && exit ${PIPESTATUS[0]} - name: Fakesign app - run: make fakesign | tee -a build.log + run: make fakesign | tee -a build/logs/build.log - name: Convert to IPA - run: make ipa | tee -a build.log + run: make ipa | tee -a build/logs/build.log - - name: Encrypt build.log generated from SideStore build for upload + - name: Boot Simulator for testing + run: make -B boot-sim | tee -a build/logs/test.log + + - name: Start Recording UI tests (if DEBUG_RECORD_TESTS is set to 1) + if: ${{ vars.DEBUG_RECORD_TESTS == '1' }} + run: | + nohup xcrun simctl io booted recordVideo test-recording.mp4 test-recording.log 2>&1 & + RECORD_PID=$! + echo "RECORD_PID=$RECORD_PID" >> $GITHUB_ENV + + # build will be up-to-date from previous step so here only test will be executed directly + - name: Run SideStore Tests + # using 'tee' to intercept stdout and log for detailed build-log + run: | + NSUnbufferedIO=YES make -B build-and-test 2>&1 | tee -a build/logs/test.log | xcbeautify --renderer github-actions && exit ${PIPESTATUS[0]} + # NSUnbufferedIO=YES make boot-sim build-and-test 2>&1 | tee build/logs/test.log | xcpretty -r junit --output ./build/tests/test-results.xml && exit ${PIPESTATUS[0]} + + - name: Stop Recording tests + if: ${{ always() && env.RECORD_PID != '' }} + run: | + kill -INT ${{ env.RECORD_PID }} + + - name: List Files and Build artifacts + if: always() + run: | + echo ">>>>>>>>> Workdir <<<<<<<<<<" + ls -la . + echo "" + + echo ">>>>>>>>> Build <<<<<<<<<<" + find build -maxdepth 3 -exec ls -ld {} + || true # List contents if directory exists + echo "" + + echo ">>>>>>>>> SideStore.xcarchive <<<<<<<<<<" + find SideStore.xcarchive -maxdepth 3 -exec ls -ld {} + || true # List contents if directory exists + echo "" + + - name: Encrypt build-logs for upload + id: encrypt-build-log + if: always() run: | DEFAULT_BUILD_LOG_PASSWORD=12345 @@ -257,19 +296,67 @@ jobs: echo "Warning: BUILD_LOG_ZIP_PASSWORD is not set. Defaulting to '${DEFAULT_BUILD_LOG_PASSWORD}'." fi - if [ ! -f build.log ]; then - echo "Warning: build.log is missing, creating a dummy log..." - echo "Error: build.log was missing, This is a dummy placeholder file..." > build.log + pushd build/logs && zip -e -P "$BUILD_LOG_ZIP_PASSWORD" ../../encrypted-build-logs.zip * || popd + echo "::set-output name=encrypted::true" + + - name: Upload encrypted-build-logs.zip + id: attach-encrypted-build-log + if: always() && steps.encrypt-build-log.outputs.encrypted == 'true' + uses: actions/upload-artifact@v4 + with: + name: encrypted-build-logs-${{ steps.version.outputs.version }}.zip + path: encrypted-build-logs.zip + + - name: Print test-recording.log contents (if exists) + if: ${{ always() && env.RECORD_PID != '' }} + run: | + if [ -f test-recording.log ]; then + echo "test-recording.log found. Its contents:" + cat test-recording.log + else + echo "test-recording.log not found." fi - zip -e -P "$BUILD_LOG_ZIP_PASSWORD" encrypted-build_log.zip build.log - - - name: List Files after SideStore build + - name: Check for test-recording.mp4 presence + id: check-recording + if: ${{ always() && env.RECORD_PID != '' }} run: | - echo ">>>>>>>>> Workdir <<<<<<<<<<" - ls -la . - echo "" + if [ -f test-recording.mp4 ]; then + echo "::set-output name=found::true" + echo "test-recording.mp4 found." + else + echo "test-recording.mp4 not found, skipping upload." + echo "::set-output name=found::false" + fi + + - name: Upload test-recording.mp4 + id: upload-recording + if: ${{ always() && steps.check-recording.outputs.found == 'true' }} + uses: actions/upload-artifact@v4 + with: + name: test-recording-${{ steps.version.outputs.version }}.mp4 + path: test-recording.mp4 + - name: Upload Test Artifacts + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ steps.version.outputs.version }}.zip + path: ./build/tests/* + + - name: Upload SideStore.ipa Artifact + uses: actions/upload-artifact@v4 + with: + name: SideStore-${{ steps.version.outputs.version }}.ipa + path: SideStore.ipa + + - name: Upload *.dSYM Artifact + uses: actions/upload-artifact@v4 + with: + name: SideStore-${{ steps.version.outputs.version }}-dSYM + path: ./SideStore.xcarchive/dSYMs/* + + + - name: Get current date id: date run: echo "date=$(date -u +'%c')" >> $GITHUB_OUTPUT @@ -288,7 +375,7 @@ jobs: release: ${{ inputs.release_name }} tag: ${{ inputs.release_tag }} prerelease: ${{ inputs.is_beta }} - files: SideStore.ipa SideStore.dSYMs.zip encrypted-build_log.zip + files: SideStore.ipa SideStore.dSYMs.zip encrypted-build-logs.zip body: | This is an ⚠️ **EXPERIMENTAL** ⚠️ ${{ inputs.release_name }} build for commit [${{ github.sha }}](https://github.com/${{ github.repository }}/commit/${{ github.sha }}). @@ -323,33 +410,6 @@ jobs: git push --verbose popd - - name: Add version to IPA file name - run: cp SideStore.ipa SideStore-${{ steps.version.outputs.version }}.ipa - - - name: Upload SideStore.ipa Artifact - uses: actions/upload-artifact@v4 - with: - name: SideStore-${{ steps.version.outputs.version }}.ipa - path: SideStore-${{ steps.version.outputs.version }}.ipa - - - name: Upload *.dSYM Artifact - uses: actions/upload-artifact@v4 - with: - name: SideStore-${{ steps.version.outputs.version }}-dSYM - path: ./SideStore.xcarchive/dSYMs/* - - - name: Upload Test Artifact - uses: actions/upload-artifact@v4 - with: - name: test-results-${{ steps.version.outputs.version }}.zip - path: ./build/tests/* - - - name: Upload encrypted-build_log.zip - uses: actions/upload-artifact@v4 - with: - name: encrypted-build_log.zip - path: encrypted-build_log.zip - - name: Get formatted date run: | FORMATTED_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") diff --git a/.gitignore b/.gitignore index 0d7ee15c..d52ec450 100644 --- a/.gitignore +++ b/.gitignore @@ -64,7 +64,3 @@ SideStore/.skip-prebuilt-fetch-em_proxy # coz SPM then resolves packages using the stale entries in this file *.xcodeproj/**/Package.resolved *.xcworkspace/**/Package.resolved - -# build/test artifacts -build.log -report.html diff --git a/Makefile b/Makefile index 2bbf3f72..7a20d0ea 100755 --- a/Makefile +++ b/Makefile @@ -201,9 +201,17 @@ boot-sim: else \ echo "Booting simulator 'iPhone 16 Pro'..."; \ xcrun simctl boot "iPhone 16 Pro"; \ + \ + if xcrun simctl list devices "iPhone 16 Pro" | grep -q "Booted"; then \ + echo "Simulator 'iPhone 16 Pro' is now booted."; \ + else \ + echo "Simulator bootup failed..."; \ + exit 1; \ + fi \ fi build-and-test: + @rm -rf build/tests/test-results.xcresult @echo ">>>>>>>>> BUILD_CONFIG is set to '$(BUILD_CONFIG)', Building for $(BUILD_CONFIG) mode! <<<<<<<<<<" @echo "" @echo "Performing a build and running tests..." @@ -342,7 +350,7 @@ ipa-altbackup: checkPaths copy-altbackup @mkdir -p "$(ALT_APP_PAYLOAD_DST)/$(TARGET_NAME)" @echo " Copying from $(ALT_APP_SRC) into $(ALT_APP_PAYLOAD_DST)" @cp -R -f "$(ALT_APP_SRC)/." "$(ALT_APP_PAYLOAD_DST)/$(TARGET_NAME)" - @pushd "$(ALT_APP_DST_ARCHIVE)" && zip -r "../../$(ALT_APP_IPA_DST)" Payload && popd + @pushd "$(ALT_APP_DST_ARCHIVE)" && zip -r "../../$(ALT_APP_IPA_DST)" Payload || popd @cp -f "$(ALT_APP_IPA_DST)" AltStore/Resources @echo " IPA created: AltStore/Resources/AltBackup.ipa" @@ -351,11 +359,8 @@ clean-altbackup: @echo "====> Cleaning up AltBackup related artifacts <====" @rm -rf build/altbackup.xcarchive/ @rm -f build/AltBackup.ipa - @rm -f AltStore/Resources/AltBackup.ipa + #@rm -f AltStore/Resources/AltBackup.ipa clean: clean-altbackup - @rm -rf *.xcarchive/ - @rm -rf *.dSYM/ @rm -rf SideStore.ipa @rm -rf build/ - @rm -rf Payload/ From 35e3cf1e14865f9badc58987b800e4139d9505ca Mon Sep 17 00:00:00 2001 From: Magesh K <47920326+mahee96@users.noreply.github.com> Date: Sat, 22 Feb 2025 18:35:23 +0530 Subject: [PATCH 12/25] - UITests: Fixed some issues with CI simulator delays --- SideStore/Tests/UITests/UITests.swift | 42 +++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/SideStore/Tests/UITests/UITests.swift b/SideStore/Tests/UITests/UITests.swift index 9137e6bf..ab3955aa 100644 --- a/SideStore/Tests/UITests/UITests.swift +++ b/SideStore/Tests/UITests/UITests.swift @@ -15,10 +15,19 @@ final class UITests: XCTestCase { private static let springboard_app = XCUIApplication(bundleIdentifier: "com.apple.springboard") private static let spotlight_app = XCUIApplication(bundleIdentifier: "com.apple.Spotlight") + private static let searchBar = spotlight_app.textFields["SpotlightSearchField"] + private static let APP_NAME = "SideStore" override func setUpWithError() throws { // ignore spotlight it it was shown + let searchBar = Self.searchBar + if searchBar.exists { + let clearButton = searchBar.buttons["Clear text"] + if clearButton.exists{ + clearButton.tap() + } + } Self.springboard_app.tap() // Put setup code here. This method is called before the invocation of each test method in the class. @@ -44,7 +53,8 @@ final class UITests: XCTestCase { let systemAlert = Self.springboard_app.alerts["“\(Self.APP_NAME)” Would Like to Send You Notifications"] - XCTAssertTrue(systemAlert.waitForExistence(timeout: 0.2), "Notifications alert did not appear") + // if it exists keep going immediately else wait for upto 5 sec with polling every 1 sec for existence + XCTAssertTrue(systemAlert.exists || systemAlert.waitForExistence(timeout: 5), "Notifications alert did not appear") systemAlert.scrollViews.otherElements.buttons["Allow"].tap() // Do the actual validation @@ -58,7 +68,8 @@ final class UITests: XCTestCase { let systemAlert = Self.springboard_app.alerts["“\(Self.APP_NAME)” Would Like to Send You Notifications"] - XCTAssertTrue(systemAlert.waitForExistence(timeout: 0.2), "Notifications alert did not appear") + // if it exists keep going immediately else wait for upto 5 sec with polling every 1 sec for existence + XCTAssertTrue(systemAlert.exists || systemAlert.waitForExistence(timeout: 5), "Notifications alert did not appear") systemAlert.scrollViews.otherElements.buttons["Allow"].tap() // Do the actual validation @@ -87,21 +98,29 @@ private extension UITests { // XCUIDevice.shared.press(.home) springboard_app.swipeDown() - let searchBar = spotlight_app.textFields["SpotlightSearchField"] + let searchBar = Self.searchBar + _ = searchBar.exists || searchBar.waitForExistence(timeout: 5) searchBar.typeText(APP_NAME) // Rest of the deletion flow... let appIcon = spotlight_app.icons[APP_NAME] - if appIcon.waitForExistence(timeout: 0.2) { + // if it exists keep going immediately else wait for upto 5 sec with polling every 1 sec for existence + if appIcon.exists || appIcon.waitForExistence(timeout: 5) { appIcon.press(forDuration: 1) let deleteAppButton = spotlight_app.buttons["Delete App"] + _ = deleteAppButton.exists || deleteAppButton.waitForExistence(timeout: 5) deleteAppButton.tap() let confirmDeleteButton = springboard_app.alerts["Delete “\(APP_NAME)”?"] + _ = confirmDeleteButton.exists || confirmDeleteButton.waitForExistence(timeout: 5) confirmDeleteButton.scrollViews.otherElements.buttons["Delete"].tap() } - searchBar.buttons["Clear text"].tap() + + let clearButton = searchBar.buttons["Clear text"] + _ = clearButton.exists || clearButton.waitForExistence(timeout: 5) + clearButton.tap() + springboard_app.tap() } @@ -150,6 +169,19 @@ private extension UITests { appsSidestoreIoTextField.tap() collectionViewsQuery.staticTexts["Paste"].tap() +// if app.keyboards.buttons["Return"].exists { +// app.keyboards.buttons["Return"].tap() +// } else if app.keyboards.buttons["Done"].exists { +// app.keyboards.buttons["Done"].tap() +// } else { +// // if still exists try tapping outside of text field focus +// app.tap() +// } + + if app.keyboards.count > 0 { + appsSidestoreIoTextField.typeText("\n") // Fallback to newline so that soft kb is dismissed + } + let cellsQuery = collectionViewsQuery.cells // Data model for recommended sources. NOTE: This list order is required to be the same as that of "Add Source" Screen From e5713fa3a999428f8dadbd759de76f71c5c99cfc Mon Sep 17 00:00:00 2001 From: Magesh K <47920326+mahee96@users.noreply.github.com> Date: Sat, 22 Feb 2025 18:35:49 +0530 Subject: [PATCH 13/25] - CI: Fixes to make build re-useable --- .github/workflows/reusable-build-workflow.yml | 2 +- Makefile | 31 ++++++++++--------- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/.github/workflows/reusable-build-workflow.yml b/.github/workflows/reusable-build-workflow.yml index 74fa6cb0..4ff0558b 100644 --- a/.github/workflows/reusable-build-workflow.yml +++ b/.github/workflows/reusable-build-workflow.yml @@ -252,7 +252,7 @@ jobs: - name: Start Recording UI tests (if DEBUG_RECORD_TESTS is set to 1) if: ${{ vars.DEBUG_RECORD_TESTS == '1' }} run: | - nohup xcrun simctl io booted recordVideo test-recording.mp4 test-recording.log 2>&1 & + nohup xcrun simctl io booted recordVideo -f test-recording.mp4 --codec h264 test-recording.log 2>&1 & RECORD_PID=$! echo "RECORD_PID=$RECORD_PID" >> $GITHUB_ENV diff --git a/Makefile b/Makefile index 7a20d0ea..192bfcc1 100755 --- a/Makefile +++ b/Makefile @@ -169,6 +169,8 @@ MARKETING_VERSION ?= BUNDLE_ID_SUFFIX ?= # Common build settings for xcodebuild COMMON_BUILD_SETTINGS = \ + -workspace AltStore.xcworkspace \ + -scheme SideStore \ -sdk iphoneos \ -configuration $(BUILD_CONFIG) \ CODE_SIGNING_REQUIRED=NO \ @@ -190,11 +192,22 @@ endif build: @echo ">>>>>>>>> BUILD_CONFIG is set to '$(BUILD_CONFIG)', Building for $(BUILD_CONFIG) mode! <<<<<<<<<<" @echo "" - @xcodebuild -workspace AltStore.xcworkspace \ - -scheme SideStore \ - archive -archivePath ./SideStore \ + @xcodebuild archive -archivePath ./SideStore \ $(COMMON_BUILD_SETTINGS) +build-and-test: + @rm -rf build/tests/test-results.xcresult + @echo ">>>>>>>>> BUILD_CONFIG is set to '$(BUILD_CONFIG)', Building for $(BUILD_CONFIG) mode! <<<<<<<<<<" + @echo "" + @echo "Performing a build and running tests..." + @xcodebuild test \ + -destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=18.2' \ + -resultBundlePath build/tests/test-results.xcresult \ + $(COMMON_BUILD_SETTINGS) + + # code cov probably cause full recompilation of tests even if archive target was just invoked before tests + # -enableCodeCoverage YES \ + boot-sim: @if xcrun simctl list devices "iPhone 16 Pro" | grep -q "Booted"; then \ echo "Simulator 'iPhone 16 Pro' is already booted."; \ @@ -210,18 +223,6 @@ boot-sim: fi \ fi -build-and-test: - @rm -rf build/tests/test-results.xcresult - @echo ">>>>>>>>> BUILD_CONFIG is set to '$(BUILD_CONFIG)', Building for $(BUILD_CONFIG) mode! <<<<<<<<<<" - @echo "" - @echo "Performing a build and running tests..." - @xcodebuild test -workspace AltStore.xcworkspace \ - -scheme SideStore \ - -destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=18.2' \ - -enableCodeCoverage YES \ - -resultBundlePath build/tests/test-results.xcresult \ - $(COMMON_BUILD_SETTINGS) - clean-build: @echo "Cleaning build artifacts..." @xcodebuild clean -workspace AltStore.xcworkspace -scheme SideStore From ca38008328471a1e0244aa53bf8f6bd011ab42dc Mon Sep 17 00:00:00 2001 From: Magesh K <47920326+mahee96@users.noreply.github.com> Date: Sat, 22 Feb 2025 18:39:07 +0530 Subject: [PATCH 14/25] - .gitignore: Added more files to gitignore --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index d52ec450..9c74f983 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,9 @@ SideStore/.skip-prebuilt-fetch-em_proxy # coz SPM then resolves packages using the stale entries in this file *.xcodeproj/**/Package.resolved *.xcworkspace/**/Package.resolved + +# some more commandline build artifacts +test-recording.mp4 +test-recording.log +altstore-sources.md +local-build.sh \ No newline at end of file From 614ab4cd338c3829c8afd6a91fdd8b29b2779a35 Mon Sep 17 00:00:00 2001 From: Magesh K <47920326+mahee96@users.noreply.github.com> Date: Sat, 22 Feb 2025 19:11:13 +0530 Subject: [PATCH 15/25] - CI: improvements: dispatch simulator boot up in background and other fixes --- .github/workflows/reusable-build-workflow.yml | 16 ++++++++++--- Makefile | 24 +++++++++++-------- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/.github/workflows/reusable-build-workflow.yml b/.github/workflows/reusable-build-workflow.yml index 4ff0558b..6fa12690 100644 --- a/.github/workflows/reusable-build-workflow.yml +++ b/.github/workflows/reusable-build-workflow.yml @@ -66,6 +66,12 @@ jobs: with: submodules: recursive + # dispatch simulator boot in bg coz it take a while to boot-up fresh + - name: Boot Simulator for testing + run: | + mkdir -p build/logs + make -B boot-sim-async | tee -a build/logs/test.log + - name: Install dependencies - ldid & xcbeautify & xcpretty run: | brew install ldid xcbeautify @@ -246,8 +252,12 @@ jobs: - name: Convert to IPA run: make ipa | tee -a build/logs/build.log - - name: Boot Simulator for testing - run: make -B boot-sim | tee -a build/logs/test.log + + # we expect simulator to have been booted by now, so exit otherwise + - name: Simulator Boot Check + run: | + mkdir -p build/logs + make -B sim-boot-check | tee -a build/logs/test.log - name: Start Recording UI tests (if DEBUG_RECORD_TESTS is set to 1) if: ${{ vars.DEBUG_RECORD_TESTS == '1' }} @@ -261,7 +271,7 @@ jobs: # using 'tee' to intercept stdout and log for detailed build-log run: | NSUnbufferedIO=YES make -B build-and-test 2>&1 | tee -a build/logs/test.log | xcbeautify --renderer github-actions && exit ${PIPESTATUS[0]} - # NSUnbufferedIO=YES make boot-sim build-and-test 2>&1 | tee build/logs/test.log | xcpretty -r junit --output ./build/tests/test-results.xml && exit ${PIPESTATUS[0]} + # NSUnbufferedIO=YES make -B build-and-test 2>&1 | tee build/logs/test.log | xcpretty -r junit --output ./build/tests/test-results.xml && exit ${PIPESTATUS[0]} - name: Stop Recording tests if: ${{ always() && env.RECORD_PID != '' }} diff --git a/Makefile b/Makefile index 192bfcc1..5a523e76 100755 --- a/Makefile +++ b/Makefile @@ -208,19 +208,23 @@ build-and-test: # code cov probably cause full recompilation of tests even if archive target was just invoked before tests # -enableCodeCoverage YES \ -boot-sim: +boot-sim-async: @if xcrun simctl list devices "iPhone 16 Pro" | grep -q "Booted"; then \ echo "Simulator 'iPhone 16 Pro' is already booted."; \ else \ - echo "Booting simulator 'iPhone 16 Pro'..."; \ - xcrun simctl boot "iPhone 16 Pro"; \ - \ - if xcrun simctl list devices "iPhone 16 Pro" | grep -q "Booted"; then \ - echo "Simulator 'iPhone 16 Pro' is now booted."; \ - else \ - echo "Simulator bootup failed..."; \ - exit 1; \ - fi \ + echo "Booting simulator 'iPhone 16 Pro' asynchronously..."; \ + # Dispatch boot in the background + xcrun simctl boot "iPhone 16 Pro" & \ + echo "Simulator boot command dispatched."; \ + fi + +sim-boot-check: + @echo "Checking simulator boot status..." + @if xcrun simctl list devices "iPhone 16 Pro" | grep -q "Booted"; then \ + echo "Simulator 'iPhone 16 Pro' is booted."; \ + else \ + echo "Simulator bootup failed or is not booted yet."; \ + exit 1; \ fi clean-build: From ebdd0d4cb4a01f07854d2a31f011a9987377ed53 Mon Sep 17 00:00:00 2001 From: Magesh K <47920326+mahee96@users.noreply.github.com> Date: Sat, 22 Feb 2025 19:55:05 +0530 Subject: [PATCH 16/25] - UITest-Fix: use home-screen(springboard) to delete the app coz sometime spotlight indexing is not up-to-date and this causes issues. --- SideStore/Tests/UITests/UITests.swift | 60 +++++++++++++++++++++------ 1 file changed, 48 insertions(+), 12 deletions(-) diff --git a/SideStore/Tests/UITests/UITests.swift b/SideStore/Tests/UITests/UITests.swift index ab3955aa..736fb5c7 100644 --- a/SideStore/Tests/UITests/UITests.swift +++ b/SideStore/Tests/UITests/UITests.swift @@ -20,18 +20,10 @@ final class UITests: XCTestCase { private static let APP_NAME = "SideStore" override func setUpWithError() throws { - // ignore spotlight it it was shown - let searchBar = Self.searchBar - if searchBar.exists { - let clearButton = searchBar.buttons["Clear text"] - if clearButton.exists{ - clearButton.tap() - } - } - Self.springboard_app.tap() - // Put setup code here. This method is called before the invocation of each test method in the class. - +// Self.dismissSpotlight() + Self.dismissSpringboardAlerts() + // In UI tests it is usually best to stop immediately when a failure occurs. continueAfterFailure = false @@ -40,7 +32,8 @@ final class UITests: XCTestCase { override func tearDownWithError() throws { // Put teardown code here. This method is called after the invocation of each test method in the class. - Self.deleteMyApp() +// Self.deleteMyApp() + Self.deleteMyApp2() super.tearDown() } @@ -91,6 +84,17 @@ final class UITests: XCTestCase { // Helpers private extension UITests { + class func dismissSpotlight(){ + // ignore spotlight if it was shown + if searchBar.exists { + let clearButton = searchBar.buttons["Clear text"] + if clearButton.exists{ + clearButton.tap() + } + } + springboard_app.tap() + } + class func deleteMyApp() { XCUIApplication().terminate() dismissSpringboardAlerts() @@ -124,6 +128,38 @@ private extension UITests { springboard_app.tap() } + class func deleteMyApp2() { + XCUIApplication().terminate() + dismissSpringboardAlerts() + + // Rest of the deletion flow... + let appIcon = springboard_app.icons[APP_NAME] + // if it exists keep going immediately else wait for upto 5 sec with polling every 1 sec for existence + if appIcon.exists || appIcon.waitForExistence(timeout: 5) { + appIcon.press(forDuration: 1) + + do { + let button = springboard_app.buttons["Remove App"] + _ = button.exists || button.waitForExistence(timeout: 5) + button.tap() + } + do { + let button = springboard_app.buttons["Delete App"] + _ = button.waitForExistence(timeout: 0.3) + button.tap() + } + do { + let button = springboard_app.buttons["Delete"] + _ = button.waitForExistence(timeout: 0.3) + button.tap() + } + + // Press home once to make the icons stop wiggling + XCUIDevice.shared.press(.home) + } + } + + class func dismissSpringboardAlerts() { for alert in springboard_app.alerts.allElementsBoundByIndex { From 348a24d88528244b9c3e02974952c0817a62c3ce Mon Sep 17 00:00:00 2001 From: Magesh K <47920326+mahee96@users.noreply.github.com> Date: Sat, 22 Feb 2025 20:12:39 +0530 Subject: [PATCH 17/25] - UITest-Fix: Add proper delays to wait for the UI Elements to appear on screen --- SideStore/Tests/UITests/UITests.swift | 89 +++++++++++++-------------- 1 file changed, 44 insertions(+), 45 deletions(-) diff --git a/SideStore/Tests/UITests/UITests.swift b/SideStore/Tests/UITests/UITests.swift index 736fb5c7..46bdf94c 100644 --- a/SideStore/Tests/UITests/UITests.swift +++ b/SideStore/Tests/UITests/UITests.swift @@ -179,6 +179,45 @@ private extension UITests { // Test guts (definition) private extension UITests { + + private func performBulkAdd( + app: XCUIApplication, + sorucesMapping: [(identifier: String, alertTitle: String, requiresSwipe: Bool)], + cellsQuery: XCUIElementQuery + ) throws { + + // Tap on each textInputSources source's "add" button. + for source in sorucesMapping { + let sourceButton = cellsQuery.otherElements + .containing(.button, identifier: source.identifier) + .children(matching: .button)[source.identifier] + _ = sourceButton.exists || sourceButton.waitForExistence(timeout: 5) // this can come from internet fetch, so give it ample time before timeout + +// let addButton = sourceButton.children(matching: .button).firstMatch + let addButton = sourceButton.children(matching: .button)["add"] + _ = addButton.exists || addButton.waitForExistence(timeout: 0.3) + addButton.tap() + + if source.requiresSwipe { + sourceButton.swipeUp(velocity: .slow) // Swipe up if needed. + } + } + + // Commit the changes by tapping "Done". + app.navigationBars["Add Source"].buttons["Done"].tap() + + // Accept each source addition via alert. + for source in sorucesMapping { + let alertIdentifier = "Would you like to add the source “\(source.alertTitle)”?" + let addSourceButton = app.alerts[alertIdentifier] + .scrollViews.otherElements.buttons["Add Source"] + _ = addSourceButton.exists || addSourceButton.waitForExistence(timeout: 0.3) + addSourceButton.tap() + } + } + + + private func performBulkAddingInputSources(for app: XCUIApplication) throws { // set content into clipboard (for bulk add (paste)) @@ -201,6 +240,7 @@ private extension UITests { let collectionViewsQuery = app.collectionViews let appsSidestoreIoTextField = collectionViewsQuery.textFields["apps.sidestore.io"] + _ = appsSidestoreIoTextField.exists || appsSidestoreIoTextField.waitForExistence(timeout: 5) appsSidestoreIoTextField.tap() appsSidestoreIoTextField.tap() collectionViewsQuery.staticTexts["Paste"].tap() @@ -233,30 +273,10 @@ private extension UITests { ("Quantum Source\nContains all of your favorite emulators, games, jailbreaks, utilities, and more.", "Quantum Source", false), ] - // Tap on each textInputSources source's "add" button. - for source in textInputSources { - let sourceButton = cellsQuery.otherElements - .containing(.button, identifier: source.identifier) - .children(matching: .button)[source.identifier] -// let addButton = sourceButton.children(matching: .button).firstMatch - let addButton = sourceButton.children(matching: .button)["add"] - addButton.tap() - if source.requiresSwipe { - sourceButton.swipeUp(velocity: .slow) // Swipe up if needed. - } - } - - // Commit the changes by tapping "Done". - app.navigationBars["Add Source"].buttons["Done"].tap() - - // Accept each source addition via alert. - for source in textInputSources { - let alertIdentifier = "Would you like to add the source “\(source.alertTitle)”?" - app.alerts[alertIdentifier] - .scrollViews.otherElements.buttons["Add Source"] - .tap() - } + try performBulkAdd(app: app, sorucesMapping: textInputSources, cellsQuery: cellsQuery) } + + private func performBulkAddingRecommendedSources(for app: XCUIApplication) throws { // Navigate to the Sources screen and open the Add Source view. @@ -280,28 +300,7 @@ private extension UITests { ("ThatStella7922 Source\nThe home for all apps ThatStella7922", "ThatStella7922 Source", false) ] - // Tap on each recommended source's "add" button. - for source in recommendedSources { - let sourceButton = cellsQuery.otherElements - .containing(.button, identifier: source.identifier) - .children(matching: .button)[source.identifier] - let addButton = sourceButton.children(matching: .button)["add"] - addButton.tap() - if source.requiresSwipe { - sourceButton.swipeUp() // Swipe up if needed. - } - } - - // Commit the changes by tapping "Done". - app.navigationBars["Add Source"].buttons["Done"].tap() - - // Accept each source addition via alert. - for source in recommendedSources { - let alertIdentifier = "Would you like to add the source “\(source.alertTitle)”?" - app.alerts[alertIdentifier] - .scrollViews.otherElements.buttons["Add Source"] - .tap() - } + try performBulkAdd(app: app, sorucesMapping: recommendedSources, cellsQuery: cellsQuery) } } From 359b38609b232cd6080e078cc18ee40ef3af161f Mon Sep 17 00:00:00 2001 From: Magesh K <47920326+mahee96@users.noreply.github.com> Date: Sat, 22 Feb 2025 20:30:56 +0530 Subject: [PATCH 18/25] - CI: Makefile and CI bug-fixes --- .github/workflows/reusable-build-workflow.yml | 2 ++ Makefile | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/reusable-build-workflow.yml b/.github/workflows/reusable-build-workflow.yml index 6fa12690..93dc8ae8 100644 --- a/.github/workflows/reusable-build-workflow.yml +++ b/.github/workflows/reusable-build-workflow.yml @@ -71,6 +71,7 @@ jobs: run: | mkdir -p build/logs make -B boot-sim-async | tee -a build/logs/test.log + exit ${PIPESTATUS[0]} - name: Install dependencies - ldid & xcbeautify & xcpretty run: | @@ -258,6 +259,7 @@ jobs: run: | mkdir -p build/logs make -B sim-boot-check | tee -a build/logs/test.log + exit ${PIPESTATUS[0]} - name: Start Recording UI tests (if DEBUG_RECORD_TESTS is set to 1) if: ${{ vars.DEBUG_RECORD_TESTS == '1' }} diff --git a/Makefile b/Makefile index 5a523e76..bf3beafb 100755 --- a/Makefile +++ b/Makefile @@ -213,7 +213,6 @@ boot-sim-async: echo "Simulator 'iPhone 16 Pro' is already booted."; \ else \ echo "Booting simulator 'iPhone 16 Pro' asynchronously..."; \ - # Dispatch boot in the background xcrun simctl boot "iPhone 16 Pro" & \ echo "Simulator boot command dispatched."; \ fi From 0070519736cdf105c640d9482cb0a00d4f172155 Mon Sep 17 00:00:00 2001 From: Magesh K <47920326+mahee96@users.noreply.github.com> Date: Sun, 23 Feb 2025 15:12:55 +0530 Subject: [PATCH 19/25] - UITests: Fixes for setup/teardown and added repeatability tests --- SideStore/Tests/UITests/UITests.swift | 102 ++++++++++++++++++++++++-- 1 file changed, 94 insertions(+), 8 deletions(-) diff --git a/SideStore/Tests/UITests/UITests.swift b/SideStore/Tests/UITests/UITests.swift index 46bdf94c..9a45ba68 100644 --- a/SideStore/Tests/UITests/UITests.swift +++ b/SideStore/Tests/UITests/UITests.swift @@ -22,7 +22,8 @@ final class UITests: XCTestCase { override func setUpWithError() throws { // Put setup code here. This method is called before the invocation of each test method in the class. // Self.dismissSpotlight() - Self.dismissSpringboardAlerts() +// Self.deleteMyApp() + Self.deleteMyApp2() // In UI tests it is usually best to stop immediately when a failure occurs. continueAfterFailure = false @@ -32,8 +33,6 @@ final class UITests: XCTestCase { override func tearDownWithError() throws { // Put teardown code here. This method is called after the invocation of each test method in the class. -// Self.deleteMyApp() - Self.deleteMyApp2() super.tearDown() } @@ -69,6 +68,21 @@ final class UITests: XCTestCase { try performBulkAddingInputSources(for: app) } + func testRepeatabilityForStagingInputSources() throws { + + let app = XCUIApplication() + app.launch() + + let systemAlert = Self.springboard_app.alerts["“\(Self.APP_NAME)” Would Like to Send You Notifications"] + + // if it exists keep going immediately else wait for upto 5 sec with polling every 1 sec for existence + XCTAssertTrue(systemAlert.exists || systemAlert.waitForExistence(timeout: 5), "Notifications alert did not appear") + systemAlert.scrollViews.otherElements.buttons["Allow"].tap() + + // Do the actual validation + try performRepeatabilityForStagingInputSources(for: app) + } + // @MainActor // func testLaunchPerformance() throws { @@ -154,8 +168,8 @@ private extension UITests { button.tap() } - // Press home once to make the icons stop wiggling - XCUIDevice.shared.press(.home) +// // Press home once to make the icons stop wiggling +// XCUIDevice.shared.press(.home) } } @@ -176,6 +190,17 @@ private extension UITests { } +struct SeededGenerator: RandomNumberGenerator { + var seed: UInt64 + + mutating func next() -> UInt64 { + // A basic LCG (not cryptographically secure, but fine for testing) + seed = 6364136223846793005 &* seed &+ 1 + return seed + } +} + + // Test guts (definition) private extension UITests { @@ -186,7 +211,7 @@ private extension UITests { cellsQuery: XCUIElementQuery ) throws { - // Tap on each textInputSources source's "add" button. + // Tap on each sorucesMapping source's "add" button. for source in sorucesMapping { let sourceButton = cellsQuery.otherElements .containing(.button, identifier: source.identifier) @@ -277,6 +302,67 @@ private extension UITests { } + private func performRepeatabilityForStagingInputSources(for app: XCUIApplication) throws { + + // set content into clipboard (for bulk add (paste)) + // NOTE: THIS IS AN ORDERED SEQUENCE AND MUST MATCH THE ORDER in textInputSources BELOW (Remember to take this into account when adding more entries) + UIPasteboard.general.string = """ + https://alts.lao.sb + https://taurine.app/altstore/taurinestore.json + https://randomblock1.com/altstore/apps.json + https://burritosoftware.github.io/altstore/channels/burritosource.json + https://bit.ly/40Isul6 + """.trimmedIndentation + + let app = XCUIApplication() + app.tabBars["Tab Bar"].buttons["Sources"].tap() + app.navigationBars["Sources"].buttons["Add"].tap() + + let collectionViewsQuery = app.collectionViews + let appsSidestoreIoTextField = collectionViewsQuery.textFields["apps.sidestore.io"] + _ = appsSidestoreIoTextField.exists || appsSidestoreIoTextField.waitForExistence(timeout: 5) + appsSidestoreIoTextField.tap() + appsSidestoreIoTextField.tap() + _ = appsSidestoreIoTextField.exists || appsSidestoreIoTextField.waitForExistence(timeout: 5) + collectionViewsQuery.staticTexts["Paste"].tap() + + if app.keyboards.count > 0 { + appsSidestoreIoTextField.typeText("\n") // Fallback to newline so that soft kb is dismissed + } + + let cellsQuery = collectionViewsQuery.cells + + // Data model for recommended sources. NOTE: This list order is required to be the same as that of "Add Source" Screen + let textInputSources: [(identifier: String, alertTitle: String, requiresSwipe: Bool)] = [ + ("Laoalts\nalts.lao.sb", "Laoalts", false), + ("Taurine\ntaurine.app/altstore/taurinestore.json", "Taurine", false), + ("RandomSource\nrandomblock1.com/altstore/apps.json", "RandomSource", false), + ("Burrito's AltStore\nburritosoftware.github.io/altstore/channels/burritosource.json", "Burrito's AltStore", false), + ("Qn_'s AltStore Repo\nbit.ly/40Isul6", "Qn_'s AltStore Repo", false), + ] + + let repeatCount = 3 // number of times to run the entire sequence + let timeSeed = UInt64(Date().timeIntervalSince1970) + var seededGenerator = SeededGenerator(seed: timeSeed) // time is unique (upto microseconds) - uncomment this to use non-deterministic seed based RNG (random number generator) +// var seededGenerator = SeededGenerator(seed: 42) // fixed seed for deterministic start of this generator + + for _ in 0.. Date: Sun, 23 Feb 2025 18:04:57 +0530 Subject: [PATCH 20/25] - UITests: Added more tests for testing repeatability --- SideStore/Tests/UITests/UITests.swift | 126 ++++++++++++++++++-------- 1 file changed, 88 insertions(+), 38 deletions(-) diff --git a/SideStore/Tests/UITests/UITests.swift b/SideStore/Tests/UITests/UITests.swift index 9a45ba68..0fc8aa8d 100644 --- a/SideStore/Tests/UITests/UITests.swift +++ b/SideStore/Tests/UITests/UITests.swift @@ -83,6 +83,21 @@ final class UITests: XCTestCase { try performRepeatabilityForStagingInputSources(for: app) } + func testRepeatabilityForStagingRecommendedSources() throws { + + let app = XCUIApplication() + app.launch() + + let systemAlert = Self.springboard_app.alerts["“\(Self.APP_NAME)” Would Like to Send You Notifications"] + + // if it exists keep going immediately else wait for upto 5 sec with polling every 1 sec for existence + XCTAssertTrue(systemAlert.exists || systemAlert.waitForExistence(timeout: 5), "Notifications alert did not appear") + systemAlert.scrollViews.otherElements.buttons["Allow"].tap() + + // Do the actual validation + try performRepeatabilityForStagingRecommendedSources(for: app) + } + // @MainActor // func testLaunchPerformance() throws { @@ -207,32 +222,18 @@ private extension UITests { private func performBulkAdd( app: XCUIApplication, - sorucesMapping: [(identifier: String, alertTitle: String, requiresSwipe: Bool)], + sourceMappings: [(identifier: String, alertTitle: String, requiresSwipe: Bool)], cellsQuery: XCUIElementQuery ) throws { - // Tap on each sorucesMapping source's "add" button. - for source in sorucesMapping { - let sourceButton = cellsQuery.otherElements - .containing(.button, identifier: source.identifier) - .children(matching: .button)[source.identifier] - _ = sourceButton.exists || sourceButton.waitForExistence(timeout: 5) // this can come from internet fetch, so give it ample time before timeout - -// let addButton = sourceButton.children(matching: .button).firstMatch - let addButton = sourceButton.children(matching: .button)["add"] - _ = addButton.exists || addButton.waitForExistence(timeout: 0.3) - addButton.tap() - - if source.requiresSwipe { - sourceButton.swipeUp(velocity: .slow) // Swipe up if needed. - } - } + // Tap on each sourceMappings source's "add" button. + try tapAddForThesePickedSources(app: app, sourceMappings: sourceMappings, cellsQuery: cellsQuery) // Commit the changes by tapping "Done". app.navigationBars["Add Source"].buttons["Done"].tap() // Accept each source addition via alert. - for source in sorucesMapping { + for source in sourceMappings { let alertIdentifier = "Would you like to add the source “\(source.alertTitle)”?" let addSourceButton = app.alerts[alertIdentifier] .scrollViews.otherElements.buttons["Add Source"] @@ -298,7 +299,7 @@ private extension UITests { ("Quantum Source\nContains all of your favorite emulators, games, jailbreaks, utilities, and more.", "Quantum Source", false), ] - try performBulkAdd(app: app, sorucesMapping: textInputSources, cellsQuery: cellsQuery) + try performBulkAdd(app: app, sourceMappings: textInputSources, cellsQuery: cellsQuery) } @@ -341,28 +342,55 @@ private extension UITests { ("Qn_'s AltStore Repo\nbit.ly/40Isul6", "Qn_'s AltStore Repo", false), ] - let repeatCount = 3 // number of times to run the entire sequence - let timeSeed = UInt64(Date().timeIntervalSince1970) - var seededGenerator = SeededGenerator(seed: timeSeed) // time is unique (upto microseconds) - uncomment this to use non-deterministic seed based RNG (random number generator) -// var seededGenerator = SeededGenerator(seed: 42) // fixed seed for deterministic start of this generator + let repeatCount = 3 // number of times to run the entire sequence + let timeSeed = UInt64(Date().timeIntervalSince1970) // time is unique (upto microseconds) - uncomment this to use non-deterministic seed based RNG (random number generator) + + try repeatabilityTest(app: app, sourceMappings: textInputSources, cellsQuery: cellsQuery, repeatCount: repeatCount, seed: timeSeed) + } + + private func repeatabilityTest( + app: XCUIApplication, + sourceMappings: [(identifier: String, alertTitle: String, requiresSwipe: Bool)], + cellsQuery: XCUIElementQuery, + repeatCount: Int = 1, // number of times to run the entire sequence + seed: UInt64 = 42 // default = fixed seed for deterministic start of this generator + ) throws { + let seededGenerator = SeededGenerator(seed: seed) for _ in 0.. Date: Sun, 23 Feb 2025 18:07:42 +0530 Subject: [PATCH 21/25] - CI: Split sequential steps into build, deploy, publish jobs and use concurrency for build and test --- .github/workflows/reusable-build-workflow.yml | 453 +++++++++++++----- 1 file changed, 326 insertions(+), 127 deletions(-) diff --git a/.github/workflows/reusable-build-workflow.yml b/.github/workflows/reusable-build-workflow.yml index 93dc8ae8..4fd821a1 100644 --- a/.github/workflows/reusable-build-workflow.yml +++ b/.github/workflows/reusable-build-workflow.yml @@ -1,4 +1,5 @@ name: Reusable SideStore Build + on: workflow_call: inputs: @@ -44,10 +45,28 @@ on: required: false jobs: - build: - name: Build and upload SideStore ${{ inputs.release_tag }} releases + serialize: + name: Wait for other jobs concurrency: group: build-number-increment # serialize for build num cache access + strategy: + fail-fast: false + runs-on: 'macos-15' + steps: + - run: echo "No other contending jobs are running now...Build is ready to start" + - name: Set short commit hash + id: commit-id + run: | + # SHORT_COMMIT="${{ github.sha }}" + SHORT_COMMIT=${GITHUB_SHA:0:7} + echo "Short commit hash: $SHORT_COMMIT" + echo "SHORT_COMMIT=$SHORT_COMMIT" >> $GITHUB_OUTPUT + outputs: + short-commit: ${{ steps.commit-id.outputs.SHORT_COMMIT }} + + build: + name: Build SideStore - ${{ inputs.release_tag }} + needs: serialize strategy: fail-fast: false matrix: @@ -56,8 +75,11 @@ jobs: version: '16.1' runs-on: ${{ matrix.os }} - steps: + outputs: + version: ${{ steps.version.outputs.version }} + release-channel: ${{ steps.release-channel.outputs.RELEASE_CHANNEL }} + steps: - name: Set beta status run: echo "IS_BETA=${{ inputs.is_beta }}" >> $GITHUB_ENV @@ -66,13 +88,6 @@ jobs: with: submodules: recursive - # dispatch simulator boot in bg coz it take a while to boot-up fresh - - name: Boot Simulator for testing - run: | - mkdir -p build/logs - make -B boot-sim-async | tee -a build/logs/test.log - exit ${PIPESTATUS[0]} - - name: Install dependencies - ldid & xcbeautify & xcpretty run: | brew install ldid xcbeautify @@ -96,7 +111,7 @@ jobs: ref: ${{ env.ref }} token: ${{ secrets.CROSS_REPO_PUSH_KEY }} path: 'SideStore/beta-build-num' - + - name: Copy build_number.txt to repo root if: ${{ inputs.is_beta }} run: | @@ -108,13 +123,16 @@ jobs: run: | echo "cat Build.xcconfig" cat Build.xcconfig - + - name: Set Release Channel info for build number bumper + id: release-channel run: | - echo "RELEASE_CHANNEL=${{ inputs.release_tag }}" >> $GITHUB_ENV + RELEASE_CHANNEL="${{ inputs.release_tag }}" + echo "RELEASE_CHANNEL=${RELEASE_CHANNEL}" >> $GITHUB_ENV + echo "RELEASE_CHANNEL=${RELEASE_CHANNEL}" >> $GITHUB_OUTPUT echo "RELEASE_CHANNEL=${RELEASE_CHANNEL}" - + - name: Increase build number for beta builds if: ${{ inputs.is_beta }} run: | @@ -127,13 +145,6 @@ jobs: echo "version=$version" >> $GITHUB_OUTPUT echo "version=$version" - - name: Get short commit hash - run: | - # SHORT_COMMIT="${{ github.sha }}" - SHORT_COMMIT=${GITHUB_SHA:0:7} - echo "Short commit hash: $SHORT_COMMIT" - echo "SHORT_COMMIT=$SHORT_COMMIT" >> $GITHUB_ENV - - name: Set MARKETING_VERSION if: ${{ inputs.is_beta }} run: | @@ -145,7 +156,7 @@ jobs: build_num=$(echo "${{ steps.version.outputs.version }}" | sed -E 's/.*\.([0-9]+)\+.*/\1/') # Combine them into the final output - MARKETING_VERSION="${version}-${date}.${build_num}+${SHORT_COMMIT}" + MARKETING_VERSION="${version}-${date}.${build_num}+${{ needs.serialize.outputs.short-commit }}" echo "MARKETING_VERSION=$MARKETING_VERSION" >> $GITHUB_ENV echo "MARKETING_VERSION=$MARKETING_VERSION" @@ -161,16 +172,16 @@ jobs: with: xcode-version: ${{ matrix.version }} - - name: Cache Build + - name: (Build) Cache Build uses: irgaly/xcode-cache@v1 with: - key: xcode-cache-deriveddata-${{ github.sha }} - restore-keys: xcode-cache-deriveddata- - swiftpm-cache-key: xcode-cache-sourcedata-${{ github.sha }} + key: xcode-cache-deriveddata-build-${{ github.sha }} + restore-keys: xcode-cache-deriveddata-build- + swiftpm-cache-key: xcode-cache-sourcedata-build-${{ github.sha }} swiftpm-cache-restore-keys: | - xcode-cache-sourcedata- + xcode-cache-sourcedata-build- - - name: Restore Pods from Cache (Exact match) + - name: (Build) Restore Pods from Cache (Exact match) id: pods-restore uses: actions/cache/restore@v3 with: @@ -178,12 +189,12 @@ jobs: ./Podfile.lock ./Pods/ ./AltStore.xcworkspace/ - key: pods-cache-${{ hashFiles('Podfile') }} + key: pods-cache-build-${{ hashFiles('Podfile') }} # restore-keys: | # commented out to strictly check cache for this particular podfile # pods-cache- - - - name: Restore Pods from Cache (Last Available) - if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }} + + - name: (Build) Restore Pods from Cache (Last Available) + if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }} id: pods-restore-recent uses: actions/cache/restore@v3 with: @@ -191,13 +202,13 @@ jobs: ./Podfile.lock ./Pods/ ./AltStore.xcworkspace/ - key: pods-cache- + key: pods-cache-build- - - name: Install CocoaPods + - name: (Build) Install CocoaPods run: pod install - - name: Save Pods to Cache + - name: (Build) Save Pods to Cache id: save-pods if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }} uses: actions/cache/save@v3 @@ -206,15 +217,15 @@ jobs: ./Podfile.lock ./Pods/ ./AltStore.xcworkspace/ - key: pods-cache-${{ hashFiles('Podfile') }} + key: pods-cache-build-${{ hashFiles('Podfile') }} - - name: Clean previous build artifacts + - name: (Build) Clean previous build artifacts # using 'tee' to intercept stdout and log for detailed build-log run: | make clean mkdir -p build/logs - - - name: List Files and derived data + + - name: (Build) List Files and derived data if: always() run: | echo ">>>>>>>>> Workdir <<<<<<<<<<" @@ -241,7 +252,7 @@ jobs: run: | echo "BUNDLE_ID_SUFFIX=${{ inputs.bundle_id_suffix }}" >> $GITHUB_ENV - + - name: Build SideStore.xcarchive # using 'tee' to intercept stdout and log for detailed build-log run: | @@ -253,35 +264,7 @@ jobs: - name: Convert to IPA run: make ipa | tee -a build/logs/build.log - - # we expect simulator to have been booted by now, so exit otherwise - - name: Simulator Boot Check - run: | - mkdir -p build/logs - make -B sim-boot-check | tee -a build/logs/test.log - exit ${PIPESTATUS[0]} - - - name: Start Recording UI tests (if DEBUG_RECORD_TESTS is set to 1) - if: ${{ vars.DEBUG_RECORD_TESTS == '1' }} - run: | - nohup xcrun simctl io booted recordVideo -f test-recording.mp4 --codec h264 test-recording.log 2>&1 & - RECORD_PID=$! - echo "RECORD_PID=$RECORD_PID" >> $GITHUB_ENV - - # build will be up-to-date from previous step so here only test will be executed directly - - name: Run SideStore Tests - # using 'tee' to intercept stdout and log for detailed build-log - run: | - NSUnbufferedIO=YES make -B build-and-test 2>&1 | tee -a build/logs/test.log | xcbeautify --renderer github-actions && exit ${PIPESTATUS[0]} - # NSUnbufferedIO=YES make -B build-and-test 2>&1 | tee build/logs/test.log | xcpretty -r junit --output ./build/tests/test-results.xml && exit ${PIPESTATUS[0]} - - - name: Stop Recording tests - if: ${{ always() && env.RECORD_PID != '' }} - run: | - kill -INT ${{ env.RECORD_PID }} - - - name: List Files and Build artifacts - if: always() + - name: (Build) List Files and Build artifacts run: | echo ">>>>>>>>> Workdir <<<<<<<<<<" ls -la . @@ -291,13 +274,16 @@ jobs: find build -maxdepth 3 -exec ls -ld {} + || true # List contents if directory exists echo "" + echo ">>>>>>>>> SideStore <<<<<<<<<<" + find SideStore -maxdepth 3 -exec ls -ld {} + || true # List contents if directory exists + echo "" + echo ">>>>>>>>> SideStore.xcarchive <<<<<<<<<<" find SideStore.xcarchive -maxdepth 3 -exec ls -ld {} + || true # List contents if directory exists echo "" - name: Encrypt build-logs for upload id: encrypt-build-log - if: always() run: | DEFAULT_BUILD_LOG_PASSWORD=12345 @@ -307,68 +293,285 @@ jobs: if [ "$BUILD_LOG_ZIP_PASSWORD" == "$DEFAULT_BUILD_LOG_PASSWORD" ]; then echo "Warning: BUILD_LOG_ZIP_PASSWORD is not set. Defaulting to '${DEFAULT_BUILD_LOG_PASSWORD}'." fi - + pushd build/logs && zip -e -P "$BUILD_LOG_ZIP_PASSWORD" ../../encrypted-build-logs.zip * || popd echo "::set-output name=encrypted::true" - name: Upload encrypted-build-logs.zip id: attach-encrypted-build-log - if: always() && steps.encrypt-build-log.outputs.encrypted == 'true' + if: ${{ always() && steps.encrypt-build-log.outputs.encrypted == 'true' }} uses: actions/upload-artifact@v4 with: name: encrypted-build-logs-${{ steps.version.outputs.version }}.zip path: encrypted-build-logs.zip - - name: Print test-recording.log contents (if exists) - if: ${{ always() && env.RECORD_PID != '' }} - run: | - if [ -f test-recording.log ]; then - echo "test-recording.log found. Its contents:" - cat test-recording.log - else - echo "test-recording.log not found." - fi - - - name: Check for test-recording.mp4 presence - id: check-recording - if: ${{ always() && env.RECORD_PID != '' }} - run: | - if [ -f test-recording.mp4 ]; then - echo "::set-output name=found::true" - echo "test-recording.mp4 found." - else - echo "test-recording.mp4 not found, skipping upload." - echo "::set-output name=found::false" - fi - - - name: Upload test-recording.mp4 - id: upload-recording - if: ${{ always() && steps.check-recording.outputs.found == 'true' }} - uses: actions/upload-artifact@v4 - with: - name: test-recording-${{ steps.version.outputs.version }}.mp4 - path: test-recording.mp4 - - - name: Upload Test Artifacts - uses: actions/upload-artifact@v4 - with: - name: test-results-${{ steps.version.outputs.version }}.zip - path: ./build/tests/* - - name: Upload SideStore.ipa Artifact uses: actions/upload-artifact@v4 with: name: SideStore-${{ steps.version.outputs.version }}.ipa path: SideStore.ipa + - name: Zip dSYMs + run: zip -r -9 ./SideStore.dSYMs.zip ./SideStore.xcarchive/dSYMs + - name: Upload *.dSYM Artifact uses: actions/upload-artifact@v4 with: - name: SideStore-${{ steps.version.outputs.version }}-dSYM - path: ./SideStore.xcarchive/dSYMs/* + name: SideStore-${{ steps.version.outputs.version }}-dSYMs.zip + path: SideStore.dSYMs.zip + + - name: Zip beta-beta-build-num & update_apps.py + run: | + zip -r -9 ./beta-build-num.zip ./SideStore/beta-build-num update_apps.py + + - name: Upload beta-build-num artifact + if: ${{ inputs.is_beta }} + uses: actions/upload-artifact@v4 + with: + name: beta-build-num-${{ steps.version.outputs.version }}.zip + path: beta-build-num.zip - + # test: + # name: Test SideStore - ${{ inputs.release_tag }} + # needs: serialize + # strategy: + # fail-fast: false + # matrix: + # include: + # - os: 'macos-15' + # version: '16.1' + # runs-on: ${{ matrix.os }} + + # steps: + # - name: Checkout code + # uses: actions/checkout@v4 + # with: + # submodules: recursive + + # - name: Boot Simulator for testing + # run: | + # mkdir -p build/logs + # make -B boot-sim-async | tee -a build/logs/test.log + # exit ${PIPESTATUS[0]} + + # - name: Install dependencies - ldid & xcbeautify & xcpretty + # run: | + # brew install ldid xcbeautify + # gem install xcpretty + + # - name: Setup Xcode + # uses: maxim-lobanov/setup-xcode@v1.6.0 + # with: + # xcode-version: '16.1' + + # - name: (Test) Cache Build + # uses: irgaly/xcode-cache@v1 + # with: + # key: xcode-cache-deriveddata-test-${{ github.sha }} + # restore-keys: xcode-cache-deriveddata-test- + # swiftpm-cache-key: xcode-cache-sourcedata-test-${{ github.sha }} + # swiftpm-cache-restore-keys: | + # xcode-cache-sourcedata-test- + + # - name: (Test) Restore Pods from Cache (Exact match) + # id: pods-restore + # uses: actions/cache/restore@v3 + # with: + # path: | + # ./Podfile.lock + # ./Pods/ + # ./AltStore.xcworkspace/ + # key: pods-cache-test-${{ hashFiles('Podfile') }} + + # - name: (Test) Restore Pods from Cache (Last Available) + # if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }} + # id: pods-restore-recent + # uses: actions/cache/restore@v3 + # with: + # path: | + # ./Podfile.lock + # ./Pods/ + # ./AltStore.xcworkspace/ + # key: pods-cache-test- + + # - name: (Test) Install CocoaPods + # run: pod install + + # - name: (Test) Save Pods to Cache + # if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }} + # uses: actions/cache/save@v3 + # with: + # path: | + # ./Podfile.lock + # ./Pods/ + # ./AltStore.xcworkspace/ + # key: pods-cache-test-${{ hashFiles('Podfile') }} + + # - name: (Test) Clean previous build artifacts + # run: | + # make clean + # mkdir -p build/logs + + # - name: (Test) List Files and derived data + # run: | + # echo ">>>>>>>>> Workdir <<<<<<<<<<" + # ls -la . + # echo "" + + # echo ">>>>>>>>> Pods <<<<<<<<<<" + # find Pods -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists + # echo "" + + # echo ">>>>>>>>> SideStore <<<<<<<<<<" + # find SideStore -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists + # echo "" + + # echo ">>>>>>>>> Dependencies <<<<<<<<<<" + # find Dependencies -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists + # echo "" + + # echo ">>>>>>>>> Xcode-Derived-Data <<<<<<<<<<" + # ls -la ~/Library/Developer/Xcode/DerivedData || true # List contents if directory exists + # echo "" + + # # we expect simulator to have been booted by now, so exit otherwise + # - name: Simulator Boot Check + # run: | + # mkdir -p build/logs + # make -B sim-boot-check | tee -a build/logs/test.log + # exit ${PIPESTATUS[0]} + + # - name: Start Recording UI tests (if DEBUG_RECORD_TESTS is set to 1) + # if: ${{ vars.DEBUG_RECORD_TESTS == '1' }} + # run: | + # nohup xcrun simctl io booted recordVideo -f test-recording.mp4 --codec h264 test-recording.log 2>&1 & + # RECORD_PID=$! + # echo "RECORD_PID=$RECORD_PID" >> $GITHUB_ENV + + # # build will be up-to-date from previous step so here only test will be executed directly + # - name: Run SideStore Tests + # # using 'tee' to intercept stdout and log for detailed build-log + # run: | + # NSUnbufferedIO=YES make -B build-and-test 2>&1 | tee -a build/logs/test.log | xcbeautify --renderer github-actions && exit ${PIPESTATUS[0]} + # # NSUnbufferedIO=YES make -B build-and-test 2>&1 | tee build/logs/test.log | xcpretty -r junit --output ./build/tests/test-results.xml && exit ${PIPESTATUS[0]} + + # - name: Stop Recording tests + # if: ${{ always() && env.RECORD_PID != '' }} + # run: | + # kill -INT ${{ env.RECORD_PID }} + + # - name: (Test) List Files and Build artifacts + # if: always() + # run: | + # echo ">>>>>>>>> Workdir <<<<<<<<<<" + # ls -la . + # echo "" + + # echo ">>>>>>>>> Build <<<<<<<<<<" + # find build -maxdepth 3 -exec ls -ld {} + || true # List contents if directory exists + # echo "" + + # - name: Encrypt test-logs for upload + # id: encrypt-test-log + # if: always() + # run: | + # DEFAULT_BUILD_LOG_PASSWORD=12345 + + # BUILD_LOG_ZIP_PASSWORD=${{ secrets.BUILD_LOG_ZIP_PASSWORD }} + # BUILD_LOG_ZIP_PASSWORD=${BUILD_LOG_ZIP_PASSWORD:-$DEFAULT_BUILD_LOG_PASSWORD} + + # if [ "$BUILD_LOG_ZIP_PASSWORD" == "$DEFAULT_BUILD_LOG_PASSWORD" ]; then + # echo "Warning: BUILD_LOG_ZIP_PASSWORD is not set. Defaulting to '${DEFAULT_BUILD_LOG_PASSWORD}'." + # fi + + # pushd build/logs && zip -e -P "$BUILD_LOG_ZIP_PASSWORD" ../../encrypted-test-logs.zip * || popd + # echo "::set-output name=encrypted::true" + + # - name: Upload encrypted-test-logs.zip + # id: attach-encrypted-test-log + # if: always() && steps.encrypt-test-log.outputs.encrypted == 'true' + # uses: actions/upload-artifact@v4 + # with: + # name: encrypted-test-logs-${{ github.sha }}.zip + # path: encrypted-test-logs.zip + + # - name: Print test-recording.log contents (if exists) + # if: ${{ always() && env.RECORD_PID != '' }} + # run: | + # if [ -f test-recording.log ]; then + # echo "test-recording.log found. Its contents:" + # cat test-recording.log + # else + # echo "test-recording.log not found." + # fi + + # - name: Check for test-recording.mp4 presence + # id: check-recording + # if: ${{ always() && env.RECORD_PID != '' }} + # run: | + # if [ -f test-recording.mp4 ]; then + # echo "::set-output name=found::true" + # echo "test-recording.mp4 found." + # else + # echo "test-recording.mp4 not found, skipping upload." + # echo "::set-output name=found::false" + # fi + + # - name: Upload test-recording.mp4 + # id: upload-recording + # if: ${{ always() && steps.check-recording.outputs.found == 'true' }} + # uses: actions/upload-artifact@v4 + # with: + # name: test-recording-${GITHUB_SHA:0:7}.mp4 + # path: test-recording.mp4 + + # - name: Zip test-results + # run: zip -r -9 ./test-results.zip ./build/tests + + # - name: Upload Test Artifacts + # uses: actions/upload-artifact@v4 + # with: + # name: test-results-${GITHUB_SHA:0:7}.zip + # path: test-results.zip + + deploy: + name: Deploy SideStore - ${{ inputs.release_tag }} + runs-on: macos-15 + needs: [serialize, build] + # needs: [serialize, build, test] + steps: + - name: Download IPA artifact + uses: actions/download-artifact@v4 + with: + name: SideStore-${{ needs.build.outputs.version }}.ipa + + - name: Download dSYM artifact + uses: actions/download-artifact@v4 + with: + name: SideStore-${{ needs.build.outputs.version }}-dSYMs.zip + + - name: Download encrypted-build-logs artifact + uses: actions/download-artifact@v4 + with: + name: encrypted-build-logs-${{ needs.build.outputs.version }}.zip + + - name: Download beta-build-num artifact + if: ${{ inputs.is_beta }} + uses: actions/download-artifact@v4 + with: + name: beta-build-num-${{ needs.build.outputs.version }}.zip + - name: Un-Zip beta-beta-build-num & update_apps.py + run: | + unzip beta-build-num.zip -d . + + + - name: List files before upload + run: | + echo ">>>>>>>>> Workdir <<<<<<<<<<" + find . -maxdepth 4 -exec ls -ld {} + || true # List contents if directory exists + echo "" + - name: Get current date id: date run: echo "date=$(date -u +'%c')" >> $GITHUB_OUTPUT @@ -377,9 +580,6 @@ jobs: id: date_altstore run: echo "date=$(date -u +'%Y-%m-%d')" >> $GITHUB_OUTPUT - - name: Create dSYMs zip - run: zip -r -9 ./SideStore.dSYMs.zip ./SideStore.xcarchive/dSYMs/* - - name: Upload to releases uses: IsaacShelton/update-existing-release@v1.3.1 with: @@ -400,14 +600,11 @@ jobs: Built at (UTC): `${{ steps.date.outputs.date }}` Built at (UTC date): `${{ steps.date_altstore.outputs.date }}` Commit SHA: `${{ github.sha }}` - Version: `${{ steps.version.outputs.version }}` + Version: `${{ needs.build.outputs.version }}` - # save it - name: Publish to SideStore/beta-build-num if: ${{ inputs.is_beta }} run: | - rm SideStore/beta-build-num/build_number.txt - mv build_number.txt SideStore/beta-build-num/build_number.txt pushd SideStore/beta-build-num/ echo "Configure Git user (committer details)" @@ -416,7 +613,7 @@ jobs: echo "Adding files to commit" git add --verbose build_number.txt - git commit -m " - updated for ${{ inputs.release_tag }} - $SHORT_COMMIT deployment" || echo "No changes to commit" + git commit -m " - updated for ${{ inputs.release_tag }} - ${{ needs.serialize.outputs.short-commit }} deployment" || echo "No changes to commit" echo "Pushing to remote repo" git push --verbose @@ -451,15 +648,17 @@ jobs: # Format localized description LOCALIZED_DESCRIPTION=$(cat <> $GITHUB_ENV echo "BUNDLE_IDENTIFIER=${{ inputs.bundle_id }}" >> $GITHUB_ENV - echo "VERSION_IPA=$MARKETING_VERSION" >> $GITHUB_ENV + echo "VERSION_IPA=${{ needs.build.outputs.version }}" >> $GITHUB_ENV echo "VERSION_DATE=$FORMATTED_DATE" >> $GITHUB_ENV + echo "RELEASE_CHANNEL=${{ needs.build.outputs.release-channel }}" >> $GITHUB_ENV echo "SIZE=$IPA_SIZE" >> $GITHUB_ENV echo "SHA256=$SHA256_HASH" >> $GITHUB_ENV echo "DOWNLOAD_URL=https://github.com/SideStore/SideStore/releases/download/${{ inputs.release_tag }}/SideStore.ipa" >> $GITHUB_ENV @@ -478,10 +677,10 @@ jobs: if: ${{ inputs.is_beta && inputs.publish }} uses: actions/checkout@v4 with: - repository: 'SideStore/apps-v2.json' - ref: 'main' # this branch is shared by all beta builds, so beta build workflows are serialized - token: ${{ secrets.CROSS_REPO_PUSH_KEY }} - path: 'SideStore/apps-v2.json' + repository: 'SideStore/apps-v2.json' + ref: 'main' # this branch is shared by all beta builds, so beta build workflows are serialized + token: ${{ secrets.CROSS_REPO_PUSH_KEY }} + path: 'SideStore/apps-v2.json' # for stable builds, let the user manually edit the source.json - name: Publish to SideStore/apps-v2.json @@ -500,7 +699,7 @@ jobs: # Commit changes and push using SSH git add --verbose ./_includes/source.json - git commit -m " - updated for $SHORT_COMMIT deployment" || echo "No changes to commit" + git commit -m " - updated for ${{ needs.serialize.outputs.short-commit }} deployment" || echo "No changes to commit" git push --verbose popd From 722f67d3c76fa8496097841bee8e51f5d9c2be72 Mon Sep 17 00:00:00 2001 From: Magesh K <47920326+mahee96@users.noreply.github.com> Date: Sun, 23 Feb 2025 22:16:40 +0530 Subject: [PATCH 22/25] - UITests: Fix: exclude pojavlauncher from testing since it is unreliable and fetch fails --- .github/workflows/reusable-build-workflow.yml | 346 +++++++++--------- SideStore/Tests/UITests/UITests.swift | 4 +- 2 files changed, 175 insertions(+), 175 deletions(-) diff --git a/.github/workflows/reusable-build-workflow.yml b/.github/workflows/reusable-build-workflow.yml index 4fd821a1..5581a092 100644 --- a/.github/workflows/reusable-build-workflow.yml +++ b/.github/workflows/reusable-build-workflow.yml @@ -332,214 +332,214 @@ jobs: path: beta-build-num.zip - # test: - # name: Test SideStore - ${{ inputs.release_tag }} - # needs: serialize - # strategy: - # fail-fast: false - # matrix: - # include: - # - os: 'macos-15' - # version: '16.1' - # runs-on: ${{ matrix.os }} + test: + name: Test SideStore - ${{ inputs.release_tag }} + needs: serialize + strategy: + fail-fast: false + matrix: + include: + - os: 'macos-15' + version: '16.1' + runs-on: ${{ matrix.os }} - # steps: - # - name: Checkout code - # uses: actions/checkout@v4 - # with: - # submodules: recursive + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: recursive - # - name: Boot Simulator for testing - # run: | - # mkdir -p build/logs - # make -B boot-sim-async | tee -a build/logs/test.log - # exit ${PIPESTATUS[0]} + - name: Boot Simulator for testing + run: | + mkdir -p build/logs + make -B boot-sim-async | tee -a build/logs/test.log + exit ${PIPESTATUS[0]} - # - name: Install dependencies - ldid & xcbeautify & xcpretty - # run: | - # brew install ldid xcbeautify - # gem install xcpretty + - name: Install dependencies - ldid & xcbeautify & xcpretty + run: | + brew install ldid xcbeautify + gem install xcpretty - # - name: Setup Xcode - # uses: maxim-lobanov/setup-xcode@v1.6.0 - # with: - # xcode-version: '16.1' + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1.6.0 + with: + xcode-version: '16.1' - # - name: (Test) Cache Build - # uses: irgaly/xcode-cache@v1 - # with: - # key: xcode-cache-deriveddata-test-${{ github.sha }} - # restore-keys: xcode-cache-deriveddata-test- - # swiftpm-cache-key: xcode-cache-sourcedata-test-${{ github.sha }} - # swiftpm-cache-restore-keys: | - # xcode-cache-sourcedata-test- + - name: (Test) Cache Build + uses: irgaly/xcode-cache@v1 + with: + key: xcode-cache-deriveddata-test-${{ github.sha }} + restore-keys: xcode-cache-deriveddata-test- + swiftpm-cache-key: xcode-cache-sourcedata-test-${{ github.sha }} + swiftpm-cache-restore-keys: | + xcode-cache-sourcedata-test- - # - name: (Test) Restore Pods from Cache (Exact match) - # id: pods-restore - # uses: actions/cache/restore@v3 - # with: - # path: | - # ./Podfile.lock - # ./Pods/ - # ./AltStore.xcworkspace/ - # key: pods-cache-test-${{ hashFiles('Podfile') }} + - name: (Test) Restore Pods from Cache (Exact match) + id: pods-restore + uses: actions/cache/restore@v3 + with: + path: | + ./Podfile.lock + ./Pods/ + ./AltStore.xcworkspace/ + key: pods-cache-test-${{ hashFiles('Podfile') }} - # - name: (Test) Restore Pods from Cache (Last Available) - # if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }} - # id: pods-restore-recent - # uses: actions/cache/restore@v3 - # with: - # path: | - # ./Podfile.lock - # ./Pods/ - # ./AltStore.xcworkspace/ - # key: pods-cache-test- + - name: (Test) Restore Pods from Cache (Last Available) + if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }} + id: pods-restore-recent + uses: actions/cache/restore@v3 + with: + path: | + ./Podfile.lock + ./Pods/ + ./AltStore.xcworkspace/ + key: pods-cache-test- - # - name: (Test) Install CocoaPods - # run: pod install + - name: (Test) Install CocoaPods + run: pod install - # - name: (Test) Save Pods to Cache - # if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }} - # uses: actions/cache/save@v3 - # with: - # path: | - # ./Podfile.lock - # ./Pods/ - # ./AltStore.xcworkspace/ - # key: pods-cache-test-${{ hashFiles('Podfile') }} + - name: (Test) Save Pods to Cache + if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v3 + with: + path: | + ./Podfile.lock + ./Pods/ + ./AltStore.xcworkspace/ + key: pods-cache-test-${{ hashFiles('Podfile') }} - # - name: (Test) Clean previous build artifacts - # run: | - # make clean - # mkdir -p build/logs + - name: (Test) Clean previous build artifacts + run: | + make clean + mkdir -p build/logs - # - name: (Test) List Files and derived data - # run: | - # echo ">>>>>>>>> Workdir <<<<<<<<<<" - # ls -la . - # echo "" + - name: (Test) List Files and derived data + run: | + echo ">>>>>>>>> Workdir <<<<<<<<<<" + ls -la . + echo "" - # echo ">>>>>>>>> Pods <<<<<<<<<<" - # find Pods -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists - # echo "" + echo ">>>>>>>>> Pods <<<<<<<<<<" + find Pods -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists + echo "" - # echo ">>>>>>>>> SideStore <<<<<<<<<<" - # find SideStore -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists - # echo "" + echo ">>>>>>>>> SideStore <<<<<<<<<<" + find SideStore -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists + echo "" - # echo ">>>>>>>>> Dependencies <<<<<<<<<<" - # find Dependencies -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists - # echo "" + echo ">>>>>>>>> Dependencies <<<<<<<<<<" + find Dependencies -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists + echo "" - # echo ">>>>>>>>> Xcode-Derived-Data <<<<<<<<<<" - # ls -la ~/Library/Developer/Xcode/DerivedData || true # List contents if directory exists - # echo "" + echo ">>>>>>>>> Xcode-Derived-Data <<<<<<<<<<" + ls -la ~/Library/Developer/Xcode/DerivedData || true # List contents if directory exists + echo "" - # # we expect simulator to have been booted by now, so exit otherwise - # - name: Simulator Boot Check - # run: | - # mkdir -p build/logs - # make -B sim-boot-check | tee -a build/logs/test.log - # exit ${PIPESTATUS[0]} + # we expect simulator to have been booted by now, so exit otherwise + - name: Simulator Boot Check + run: | + mkdir -p build/logs + make -B sim-boot-check | tee -a build/logs/test.log + exit ${PIPESTATUS[0]} - # - name: Start Recording UI tests (if DEBUG_RECORD_TESTS is set to 1) - # if: ${{ vars.DEBUG_RECORD_TESTS == '1' }} - # run: | - # nohup xcrun simctl io booted recordVideo -f test-recording.mp4 --codec h264 test-recording.log 2>&1 & - # RECORD_PID=$! - # echo "RECORD_PID=$RECORD_PID" >> $GITHUB_ENV + - name: Start Recording UI tests (if DEBUG_RECORD_TESTS is set to 1) + if: ${{ vars.DEBUG_RECORD_TESTS == '1' }} + run: | + nohup xcrun simctl io booted recordVideo -f test-recording.mp4 --codec h264 test-recording.log 2>&1 & + RECORD_PID=$! + echo "RECORD_PID=$RECORD_PID" >> $GITHUB_ENV - # # build will be up-to-date from previous step so here only test will be executed directly - # - name: Run SideStore Tests - # # using 'tee' to intercept stdout and log for detailed build-log - # run: | - # NSUnbufferedIO=YES make -B build-and-test 2>&1 | tee -a build/logs/test.log | xcbeautify --renderer github-actions && exit ${PIPESTATUS[0]} - # # NSUnbufferedIO=YES make -B build-and-test 2>&1 | tee build/logs/test.log | xcpretty -r junit --output ./build/tests/test-results.xml && exit ${PIPESTATUS[0]} + # build will be up-to-date from previous step so here only test will be executed directly + - name: Run SideStore Tests + # using 'tee' to intercept stdout and log for detailed build-log + run: | + NSUnbufferedIO=YES make -B build-and-test 2>&1 | tee -a build/logs/test.log | xcbeautify --renderer github-actions && exit ${PIPESTATUS[0]} + # NSUnbufferedIO=YES make -B build-and-test 2>&1 | tee build/logs/test.log | xcpretty -r junit --output ./build/tests/test-results.xml && exit ${PIPESTATUS[0]} - # - name: Stop Recording tests - # if: ${{ always() && env.RECORD_PID != '' }} - # run: | - # kill -INT ${{ env.RECORD_PID }} + - name: Stop Recording tests + if: ${{ always() && env.RECORD_PID != '' }} + run: | + kill -INT ${{ env.RECORD_PID }} - # - name: (Test) List Files and Build artifacts - # if: always() - # run: | - # echo ">>>>>>>>> Workdir <<<<<<<<<<" - # ls -la . - # echo "" + - name: (Test) List Files and Build artifacts + if: always() + run: | + echo ">>>>>>>>> Workdir <<<<<<<<<<" + ls -la . + echo "" - # echo ">>>>>>>>> Build <<<<<<<<<<" - # find build -maxdepth 3 -exec ls -ld {} + || true # List contents if directory exists - # echo "" + echo ">>>>>>>>> Build <<<<<<<<<<" + find build -maxdepth 3 -exec ls -ld {} + || true # List contents if directory exists + echo "" - # - name: Encrypt test-logs for upload - # id: encrypt-test-log - # if: always() - # run: | - # DEFAULT_BUILD_LOG_PASSWORD=12345 + - name: Encrypt test-logs for upload + id: encrypt-test-log + if: always() + run: | + DEFAULT_BUILD_LOG_PASSWORD=12345 - # BUILD_LOG_ZIP_PASSWORD=${{ secrets.BUILD_LOG_ZIP_PASSWORD }} - # BUILD_LOG_ZIP_PASSWORD=${BUILD_LOG_ZIP_PASSWORD:-$DEFAULT_BUILD_LOG_PASSWORD} + BUILD_LOG_ZIP_PASSWORD=${{ secrets.BUILD_LOG_ZIP_PASSWORD }} + BUILD_LOG_ZIP_PASSWORD=${BUILD_LOG_ZIP_PASSWORD:-$DEFAULT_BUILD_LOG_PASSWORD} - # if [ "$BUILD_LOG_ZIP_PASSWORD" == "$DEFAULT_BUILD_LOG_PASSWORD" ]; then - # echo "Warning: BUILD_LOG_ZIP_PASSWORD is not set. Defaulting to '${DEFAULT_BUILD_LOG_PASSWORD}'." - # fi + if [ "$BUILD_LOG_ZIP_PASSWORD" == "$DEFAULT_BUILD_LOG_PASSWORD" ]; then + echo "Warning: BUILD_LOG_ZIP_PASSWORD is not set. Defaulting to '${DEFAULT_BUILD_LOG_PASSWORD}'." + fi - # pushd build/logs && zip -e -P "$BUILD_LOG_ZIP_PASSWORD" ../../encrypted-test-logs.zip * || popd - # echo "::set-output name=encrypted::true" + pushd build/logs && zip -e -P "$BUILD_LOG_ZIP_PASSWORD" ../../encrypted-test-logs.zip * || popd + echo "::set-output name=encrypted::true" - # - name: Upload encrypted-test-logs.zip - # id: attach-encrypted-test-log - # if: always() && steps.encrypt-test-log.outputs.encrypted == 'true' - # uses: actions/upload-artifact@v4 - # with: - # name: encrypted-test-logs-${{ github.sha }}.zip - # path: encrypted-test-logs.zip + - name: Upload encrypted-test-logs.zip + id: attach-encrypted-test-log + if: always() && steps.encrypt-test-log.outputs.encrypted == 'true' + uses: actions/upload-artifact@v4 + with: + name: encrypted-test-logs-${{ github.sha }}.zip + path: encrypted-test-logs.zip - # - name: Print test-recording.log contents (if exists) - # if: ${{ always() && env.RECORD_PID != '' }} - # run: | - # if [ -f test-recording.log ]; then - # echo "test-recording.log found. Its contents:" - # cat test-recording.log - # else - # echo "test-recording.log not found." - # fi + - name: Print test-recording.log contents (if exists) + if: ${{ always() && env.RECORD_PID != '' }} + run: | + if [ -f test-recording.log ]; then + echo "test-recording.log found. Its contents:" + cat test-recording.log + else + echo "test-recording.log not found." + fi - # - name: Check for test-recording.mp4 presence - # id: check-recording - # if: ${{ always() && env.RECORD_PID != '' }} - # run: | - # if [ -f test-recording.mp4 ]; then - # echo "::set-output name=found::true" - # echo "test-recording.mp4 found." - # else - # echo "test-recording.mp4 not found, skipping upload." - # echo "::set-output name=found::false" - # fi + - name: Check for test-recording.mp4 presence + id: check-recording + if: ${{ always() && env.RECORD_PID != '' }} + run: | + if [ -f test-recording.mp4 ]; then + echo "::set-output name=found::true" + echo "test-recording.mp4 found." + else + echo "test-recording.mp4 not found, skipping upload." + echo "::set-output name=found::false" + fi - # - name: Upload test-recording.mp4 - # id: upload-recording - # if: ${{ always() && steps.check-recording.outputs.found == 'true' }} - # uses: actions/upload-artifact@v4 - # with: - # name: test-recording-${GITHUB_SHA:0:7}.mp4 - # path: test-recording.mp4 + - name: Upload test-recording.mp4 + id: upload-recording + if: ${{ always() && steps.check-recording.outputs.found == 'true' }} + uses: actions/upload-artifact@v4 + with: + name: test-recording-${{ needs.serialize.outputs.short-commit }}.mp4 + path: test-recording.mp4 - # - name: Zip test-results - # run: zip -r -9 ./test-results.zip ./build/tests + - name: Zip test-results + run: zip -r -9 ./test-results.zip ./build/tests - # - name: Upload Test Artifacts - # uses: actions/upload-artifact@v4 - # with: - # name: test-results-${GITHUB_SHA:0:7}.zip - # path: test-results.zip + - name: Upload Test Artifacts + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ needs.serialize.outputs.short-commit }}.zip + path: test-results.zip deploy: name: Deploy SideStore - ${{ inputs.release_tag }} runs-on: macos-15 - needs: [serialize, build] - # needs: [serialize, build, test] + # needs: [serialize, build] + needs: [serialize, build, test] steps: - name: Download IPA artifact uses: actions/download-artifact@v4 diff --git a/SideStore/Tests/UITests/UITests.swift b/SideStore/Tests/UITests/UITests.swift index 0fc8aa8d..0cf187fb 100644 --- a/SideStore/Tests/UITests/UITests.swift +++ b/SideStore/Tests/UITests/UITests.swift @@ -379,7 +379,7 @@ private extension UITests { let sourceButton = cellsQuery.otherElements .containing(.button, identifier: source.identifier) .children(matching: .button)[source.identifier] - XCTAssert(sourceButton.exists || sourceButton.waitForExistence(timeout: 5), "Source preview for id: '\(source.alertTitle)' not found in the view") + XCTAssert(sourceButton.exists || sourceButton.waitForExistence(timeout: 10), "Source preview for id: '\(source.alertTitle)' not found in the view") // let addButton = sourceButton.children(matching: .button).firstMatch let addButton = sourceButton.children(matching: .button)["add"] @@ -407,7 +407,7 @@ private extension UITests { ("OatmealDome's AltStore Source\naltstore.oatmealdome.me", "OatmealDome's AltStore Source", true), ("UTM Repository\nVirtual machines for iOS", "UTM Repository", false), ("Flyinghead\nflyinghead.github.io/flycast-builds/altstore.json", "Flyinghead", false), - ("PojavLauncher Repository\nalt.crystall1ne.dev", "PojavLauncher Repository", false), +// ("PojavLauncher Repository\nalt.crystall1ne.dev", "PojavLauncher Repository", false), // not a stable source, sometimes becomes unreachable, so disabled ("PokeMMO\npokemmo.eu/altstore/", "PokeMMO", true), ("Odyssey\ntheodyssey.dev/altstore/odysseysource.json", "Odyssey", false), ("Yattee\nrepos.yattee.stream/alt/apps.json", "Yattee", false), From d677292bd3607bca9e063277f34c7174e7ed395d Mon Sep 17 00:00:00 2001 From: Magesh K <47920326+mahee96@users.noreply.github.com> Date: Sun, 23 Feb 2025 23:02:10 +0530 Subject: [PATCH 23/25] - CI: Fix: VERSION_IPA was not using $marketing_version --- .github/workflows/reusable-build-workflow.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/reusable-build-workflow.yml b/.github/workflows/reusable-build-workflow.yml index 5581a092..fada9914 100644 --- a/.github/workflows/reusable-build-workflow.yml +++ b/.github/workflows/reusable-build-workflow.yml @@ -77,6 +77,7 @@ jobs: runs-on: ${{ matrix.os }} outputs: version: ${{ steps.version.outputs.version }} + marketing-version: ${{ steps.marketing-version.outputs.MARKETING_VERSION }} release-channel: ${{ steps.release-channel.outputs.RELEASE_CHANNEL }} steps: @@ -147,6 +148,7 @@ jobs: - name: Set MARKETING_VERSION if: ${{ inputs.is_beta }} + id: marketing-version run: | # Extract version number (e.g., "0.6.0") version=$(echo "${{ steps.version.outputs.version }}" | sed -E 's/^[^0-9]*([0-9]+\.[0-9]+\.[0-9]+).*/\1/') @@ -159,6 +161,7 @@ jobs: MARKETING_VERSION="${version}-${date}.${build_num}+${{ needs.serialize.outputs.short-commit }}" echo "MARKETING_VERSION=$MARKETING_VERSION" >> $GITHUB_ENV + echo "MARKETING_VERSION=$MARKETING_VERSION" >> $GITHUB_OUTPUT echo "MARKETING_VERSION=$MARKETING_VERSION" - name: Echo Updated Build.xcconfig, build_number.txt @@ -656,7 +659,7 @@ jobs: echo "IS_BETA=${{ inputs.is_beta }}" >> $GITHUB_ENV echo "BUNDLE_IDENTIFIER=${{ inputs.bundle_id }}" >> $GITHUB_ENV - echo "VERSION_IPA=${{ needs.build.outputs.version }}" >> $GITHUB_ENV + echo "VERSION_IPA=${{ needs.build.outputs.marketing-version }}" >> $GITHUB_ENV echo "VERSION_DATE=$FORMATTED_DATE" >> $GITHUB_ENV echo "RELEASE_CHANNEL=${{ needs.build.outputs.release-channel }}" >> $GITHUB_ENV echo "SIZE=$IPA_SIZE" >> $GITHUB_ENV From a5aec978bb9e44e9ac3f14512440794d80eab825 Mon Sep 17 00:00:00 2001 From: Magesh K <47920326+mahee96@users.noreply.github.com> Date: Mon, 24 Feb 2025 18:51:51 +0530 Subject: [PATCH 24/25] - CI: Optimization: Split tests-build from tests-run so that tests-run failure doesn't invalidate last build in cache --- .github/workflows/reusable-build-workflow.yml | 233 ++++++++++++++---- Makefile | 22 +- 2 files changed, 211 insertions(+), 44 deletions(-) diff --git a/.github/workflows/reusable-build-workflow.yml b/.github/workflows/reusable-build-workflow.yml index fada9914..ee439d2c 100644 --- a/.github/workflows/reusable-build-workflow.yml +++ b/.github/workflows/reusable-build-workflow.yml @@ -89,10 +89,9 @@ jobs: with: submodules: recursive - - name: Install dependencies - ldid & xcbeautify & xcpretty + - name: Install dependencies - ldid & xcbeautify run: | brew install ldid xcbeautify - gem install xcpretty # for test reports - name: Set ref based on is_shared_build_num if: ${{ inputs.is_beta }} @@ -335,8 +334,8 @@ jobs: path: beta-build-num.zip - test: - name: Test SideStore - ${{ inputs.release_tag }} + tests-build: + name: Tests-Build SideStore - ${{ inputs.release_tag }} needs: serialize strategy: fail-fast: false @@ -346,6 +345,142 @@ jobs: version: '16.1' runs-on: ${{ matrix.os }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install dependencies - xcbeautify + run: | + brew install xcbeautify + + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1.6.0 + with: + xcode-version: '16.1' + + - name: (Tests-Build) Cache Build + uses: irgaly/xcode-cache@v1 + with: + key: xcode-cache-deriveddata-test-${{ github.sha }} + restore-keys: xcode-cache-deriveddata-test- + swiftpm-cache-key: xcode-cache-sourcedata-test-${{ github.sha }} + swiftpm-cache-restore-keys: | + xcode-cache-sourcedata-test- + + - name: (Tests-Build) Restore Pods from Cache (Exact match) + id: pods-restore + uses: actions/cache/restore@v3 + with: + path: | + ./Podfile.lock + ./Pods/ + ./AltStore.xcworkspace/ + key: pods-cache-test-${{ hashFiles('Podfile') }} + + - name: (Tests-Build) Restore Pods from Cache (Last Available) + if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }} + id: pods-restore-recent + uses: actions/cache/restore@v3 + with: + path: | + ./Podfile.lock + ./Pods/ + ./AltStore.xcworkspace/ + key: pods-cache-test- + + - name: (Tests-Build) Install CocoaPods + run: pod install + + - name: (Tests-Build) Save Pods to Cache + if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v3 + with: + path: | + ./Podfile.lock + ./Pods/ + ./AltStore.xcworkspace/ + key: pods-cache-test-${{ hashFiles('Podfile') }} + + - name: (Tests-Build) Clean previous build artifacts + run: | + make clean + mkdir -p build/logs + + - name: (Tests-Build) List Files and derived data + run: | + echo ">>>>>>>>> Workdir <<<<<<<<<<" + ls -la . + echo "" + + echo ">>>>>>>>> Pods <<<<<<<<<<" + find Pods -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists + echo "" + + echo ">>>>>>>>> SideStore <<<<<<<<<<" + find SideStore -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists + echo "" + + echo ">>>>>>>>> Dependencies <<<<<<<<<<" + find Dependencies -maxdepth 2 -exec ls -ld {} + || true # List contents if directory exists + echo "" + + echo ">>>>>>>>> Xcode-Derived-Data <<<<<<<<<<" + ls -la ~/Library/Developer/Xcode/DerivedData || true # List contents if directory exists + echo "" + + - name: Build SideStore Tests + # using 'tee' to intercept stdout and log for detailed build-log + run: | + NSUnbufferedIO=YES make -B build-tests 2>&1 | tee -a build/logs/tests-build.log | xcbeautify --renderer github-actions && exit ${PIPESTATUS[0]} + + - name: (Tests-Build) List Files and Build artifacts + if: always() + run: | + echo ">>>>>>>>> Workdir <<<<<<<<<<" + ls -la . + echo "" + + echo ">>>>>>>>> Build <<<<<<<<<<" + find build -maxdepth 3 -exec ls -ld {} + || true # List contents if directory exists + echo "" + + - name: Encrypt tests-build-logs for upload + id: encrypt-test-log + if: always() + run: | + DEFAULT_BUILD_LOG_PASSWORD=12345 + + BUILD_LOG_ZIP_PASSWORD=${{ secrets.BUILD_LOG_ZIP_PASSWORD }} + BUILD_LOG_ZIP_PASSWORD=${BUILD_LOG_ZIP_PASSWORD:-$DEFAULT_BUILD_LOG_PASSWORD} + + if [ "$BUILD_LOG_ZIP_PASSWORD" == "$DEFAULT_BUILD_LOG_PASSWORD" ]; then + echo "Warning: BUILD_LOG_ZIP_PASSWORD is not set. Defaulting to '${DEFAULT_BUILD_LOG_PASSWORD}'." + fi + + pushd build/logs && zip -e -P "$BUILD_LOG_ZIP_PASSWORD" ../../encrypted-tests-build-logs.zip * || popd + echo "::set-output name=encrypted::true" + + - name: Upload encrypted-tests-build-logs.zip + id: attach-encrypted-test-log + if: always() && steps.encrypt-test-log.outputs.encrypted == 'true' + uses: actions/upload-artifact@v4 + with: + name: encrypted-tests-build-logs-${{ needs.serialize.outputs.short-commit }}.zip + path: encrypted-tests-build-logs.zip + + tests-run: + name: Tests-Run SideStore - ${{ inputs.release_tag }} + needs: [serialize, tests-build] + strategy: + fail-fast: false + matrix: + include: + - os: 'macos-15' + version: '16.1' + runs-on: ${{ matrix.os }} + steps: - name: Checkout code uses: actions/checkout@v4 @@ -355,20 +490,15 @@ jobs: - name: Boot Simulator for testing run: | mkdir -p build/logs - make -B boot-sim-async | tee -a build/logs/test.log + make -B boot-sim-async | tee -a build/logs/tests-run.log exit ${PIPESTATUS[0]} - - name: Install dependencies - ldid & xcbeautify & xcpretty - run: | - brew install ldid xcbeautify - gem install xcpretty - - name: Setup Xcode uses: maxim-lobanov/setup-xcode@v1.6.0 with: xcode-version: '16.1' - - name: (Test) Cache Build + - name: (Tests-Run) Cache Build uses: irgaly/xcode-cache@v1 with: key: xcode-cache-deriveddata-test-${{ github.sha }} @@ -377,7 +507,7 @@ jobs: swiftpm-cache-restore-keys: | xcode-cache-sourcedata-test- - - name: (Test) Restore Pods from Cache (Exact match) + - name: (Tests-Run) Restore Pods from Cache (Exact match) id: pods-restore uses: actions/cache/restore@v3 with: @@ -387,7 +517,7 @@ jobs: ./AltStore.xcworkspace/ key: pods-cache-test-${{ hashFiles('Podfile') }} - - name: (Test) Restore Pods from Cache (Last Available) + - name: (Tests-Run) Restore Pods from Cache (Last Available) if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }} id: pods-restore-recent uses: actions/cache/restore@v3 @@ -398,10 +528,10 @@ jobs: ./AltStore.xcworkspace/ key: pods-cache-test- - - name: (Test) Install CocoaPods + - name: (Tests-Run) Install CocoaPods run: pod install - - name: (Test) Save Pods to Cache + - name: (Tests-Run) Save Pods to Cache if: ${{ steps.pods-restore.outputs.cache-hit != 'true' }} uses: actions/cache/save@v3 with: @@ -411,12 +541,12 @@ jobs: ./AltStore.xcworkspace/ key: pods-cache-test-${{ hashFiles('Podfile') }} - - name: (Test) Clean previous build artifacts + - name: (Tests-Run) Clean previous build artifacts run: | make clean mkdir -p build/logs - - name: (Test) List Files and derived data + - name: (Tests-Run) List Files and derived data run: | echo ">>>>>>>>> Workdir <<<<<<<<<<" ls -la . @@ -442,29 +572,28 @@ jobs: - name: Simulator Boot Check run: | mkdir -p build/logs - make -B sim-boot-check | tee -a build/logs/test.log + make -B sim-boot-check | tee -a build/logs/tests-run.log exit ${PIPESTATUS[0]} - name: Start Recording UI tests (if DEBUG_RECORD_TESTS is set to 1) if: ${{ vars.DEBUG_RECORD_TESTS == '1' }} run: | - nohup xcrun simctl io booted recordVideo -f test-recording.mp4 --codec h264 test-recording.log 2>&1 & + nohup xcrun simctl io booted recordVideo -f tests-recording.mp4 --codec h264 tests-recording.log 2>&1 & RECORD_PID=$! echo "RECORD_PID=$RECORD_PID" >> $GITHUB_ENV - # build will be up-to-date from previous step so here only test will be executed directly - name: Run SideStore Tests # using 'tee' to intercept stdout and log for detailed build-log run: | - NSUnbufferedIO=YES make -B build-and-test 2>&1 | tee -a build/logs/test.log | xcbeautify --renderer github-actions && exit ${PIPESTATUS[0]} - # NSUnbufferedIO=YES make -B build-and-test 2>&1 | tee build/logs/test.log | xcpretty -r junit --output ./build/tests/test-results.xml && exit ${PIPESTATUS[0]} + make run-tests 2>&1 | tee -a build/logs/tests-run.log && exit ${PIPESTATUS[0]} + # NSUnbufferedIO=YES make -B run-tests 2>&1 | tee build/logs/tests-run.log | xcpretty -r junit --output ./build/tests/test-results.xml && exit ${PIPESTATUS[0]} - name: Stop Recording tests if: ${{ always() && env.RECORD_PID != '' }} run: | kill -INT ${{ env.RECORD_PID }} - - name: (Test) List Files and Build artifacts + - name: (Tests-Run) List Files and Build artifacts if: always() run: | echo ">>>>>>>>> Workdir <<<<<<<<<<" @@ -475,7 +604,7 @@ jobs: find build -maxdepth 3 -exec ls -ld {} + || true # List contents if directory exists echo "" - - name: Encrypt test-logs for upload + - name: Encrypt tests-run-logs for upload id: encrypt-test-log if: always() run: | @@ -488,46 +617,46 @@ jobs: echo "Warning: BUILD_LOG_ZIP_PASSWORD is not set. Defaulting to '${DEFAULT_BUILD_LOG_PASSWORD}'." fi - pushd build/logs && zip -e -P "$BUILD_LOG_ZIP_PASSWORD" ../../encrypted-test-logs.zip * || popd + pushd build/logs && zip -e -P "$BUILD_LOG_ZIP_PASSWORD" ../../encrypted-tests-run-logs.zip * || popd echo "::set-output name=encrypted::true" - - name: Upload encrypted-test-logs.zip + - name: Upload encrypted-tests-run-logs.zip id: attach-encrypted-test-log if: always() && steps.encrypt-test-log.outputs.encrypted == 'true' uses: actions/upload-artifact@v4 with: - name: encrypted-test-logs-${{ github.sha }}.zip - path: encrypted-test-logs.zip + name: encrypted-tests-run-logs-${{ needs.serialize.outputs.short-commit }}.zip + path: encrypted-tests-run-logs.zip - - name: Print test-recording.log contents (if exists) + - name: Print tests-recording.log contents (if exists) if: ${{ always() && env.RECORD_PID != '' }} run: | - if [ -f test-recording.log ]; then - echo "test-recording.log found. Its contents:" - cat test-recording.log + if [ -f tests-recording.log ]; then + echo "tests-recording.log found. Its contents:" + cat tests-recording.log else - echo "test-recording.log not found." + echo "tests-recording.log not found." fi - - name: Check for test-recording.mp4 presence + - name: Check for tests-recording.mp4 presence id: check-recording if: ${{ always() && env.RECORD_PID != '' }} run: | - if [ -f test-recording.mp4 ]; then + if [ -f tests-recording.mp4 ]; then echo "::set-output name=found::true" - echo "test-recording.mp4 found." + echo "tests-recording.mp4 found." else - echo "test-recording.mp4 not found, skipping upload." + echo "tests-recording.mp4 not found, skipping upload." echo "::set-output name=found::false" fi - - name: Upload test-recording.mp4 + - name: Upload tests-recording.mp4 id: upload-recording if: ${{ always() && steps.check-recording.outputs.found == 'true' }} uses: actions/upload-artifact@v4 with: - name: test-recording-${{ needs.serialize.outputs.short-commit }}.mp4 - path: test-recording.mp4 + name: tests-recording-${{ needs.serialize.outputs.short-commit }}.mp4 + path: tests-recording.mp4 - name: Zip test-results run: zip -r -9 ./test-results.zip ./build/tests @@ -542,7 +671,7 @@ jobs: name: Deploy SideStore - ${{ inputs.release_tag }} runs-on: macos-15 # needs: [serialize, build] - needs: [serialize, build, test] + needs: [serialize, build, tests-build, tests-run] steps: - name: Download IPA artifact uses: actions/download-artifact@v4 @@ -559,6 +688,26 @@ jobs: with: name: encrypted-build-logs-${{ needs.build.outputs.version }}.zip + - name: Download encrypted-tests-build-logs artifact + uses: actions/download-artifact@v4 + with: + name: encrypted-tests-build-logs-${{ needs.serialize.outputs.short-commit }}.zip + + - name: Download encrypted-tests-run-logs artifact + uses: actions/download-artifact@v4 + with: + name: encrypted-tests-run-logs-${{ needs.serialize.outputs.short-commit }}.zip + + - name: Download tests-recording artifact + uses: actions/download-artifact@v4 + with: + name: tests-recording-${{ needs.serialize.outputs.short-commit }}.mp4 + + - name: Download test-results artifact + uses: actions/download-artifact@v4 + with: + name: test-results-${{ needs.serialize.outputs.short-commit }}.zip + - name: Download beta-build-num artifact if: ${{ inputs.is_beta }} uses: actions/download-artifact@v4 @@ -590,7 +739,7 @@ jobs: release: ${{ inputs.release_name }} tag: ${{ inputs.release_tag }} prerelease: ${{ inputs.is_beta }} - files: SideStore.ipa SideStore.dSYMs.zip encrypted-build-logs.zip + files: SideStore.ipa SideStore.dSYMs.zip encrypted-build-logs.zip encrypted-tests-build-logs.zip encrypted-tests-run-logs.zip test-results.zip tests-recording.mp4 body: | This is an ⚠️ **EXPERIMENTAL** ⚠️ ${{ inputs.release_name }} build for commit [${{ github.sha }}](https://github.com/${{ github.repository }}/commit/${{ github.sha }}). diff --git a/Makefile b/Makefile index bf3beafb..8bd614cd 100755 --- a/Makefile +++ b/Makefile @@ -203,10 +203,28 @@ build-and-test: @xcodebuild test \ -destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=18.2' \ -resultBundlePath build/tests/test-results.xcresult \ + -enableCodeCoverage YES \ $(COMMON_BUILD_SETTINGS) - # code cov probably cause full recompilation of tests even if archive target was just invoked before tests - # -enableCodeCoverage YES \ +build-tests: + @rm -rf build/tests/test-results.xcresult + @echo ">>>>>>>>> BUILD_CONFIG is set to '$(BUILD_CONFIG)', Building Tests for $(BUILD_CONFIG) mode! <<<<<<<<<<" + @echo "" + @echo "Performing a build-for-testing..." + @xcodebuild build-for-testing \ + -enableCodeCoverage YES \ + $(COMMON_BUILD_SETTINGS) + +run-tests: + @rm -rf build/tests/test-results.xcresult + @echo ">>>>>>>>> BUILD_CONFIG is set to '$(BUILD_CONFIG)', Testing for $(BUILD_CONFIG) mode! <<<<<<<<<<" + @echo "" + @echo "Performing a test-without-building..." + @xcodebuild test-without-building \ + -enableCodeCoverage YES \ + -resultBundlePath build/tests/test-results.xcresult \ + -destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=18.2' \ + $(COMMON_BUILD_SETTINGS) boot-sim-async: @if xcrun simctl list devices "iPhone 16 Pro" | grep -q "Booted"; then \ From 84bb1f7c0890264a04e571e8d91d234e3eeccca7 Mon Sep 17 00:00:00 2001 From: Magesh K <47920326+mahee96@users.noreply.github.com> Date: Mon, 24 Feb 2025 20:09:14 +0530 Subject: [PATCH 25/25] - Bug-Fix: use normal keyboardType instead of url keyboardType for input field in add-source view --- AltStore/Sources/Components/AddSourceTextFieldCell.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AltStore/Sources/Components/AddSourceTextFieldCell.swift b/AltStore/Sources/Components/AddSourceTextFieldCell.swift index 1a09959b..1a8af574 100644 --- a/AltStore/Sources/Components/AddSourceTextFieldCell.swift +++ b/AltStore/Sources/Components/AddSourceTextFieldCell.swift @@ -21,7 +21,7 @@ class AddSourceTextFieldCell: UICollectionViewCell self.textField.translatesAutoresizingMaskIntoConstraints = false self.textField.placeholder = "apps.sidestore.io" self.textField.textContentType = .URL - self.textField.keyboardType = .URL +// self.textField.keyboardType = .URL // we can add multiple sources now delimited by spaces/newline so we use normal keyboard not url keyboard self.textField.returnKeyType = .done self.textField.autocapitalizationType = .none self.textField.autocorrectionType = .no