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] - 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") + } +}