diff --git a/Modules/Sources/PointOfSale/Controllers/PointOfSaleItemsController.swift b/Modules/Sources/PointOfSale/Controllers/PointOfSaleItemsController.swift index 62e842d7dea..cfbae07a0b9 100644 --- a/Modules/Sources/PointOfSale/Controllers/PointOfSaleItemsController.swift +++ b/Modules/Sources/PointOfSale/Controllers/PointOfSaleItemsController.swift @@ -1,6 +1,7 @@ import Foundation import Observation import enum Yosemite.POSItem +import struct Yosemite.POSSimpleProduct import class Yosemite.PointOfSaleItemService import protocol Yosemite.PointOfSaleItemServiceProtocol import protocol Yosemite.PointOfSaleItemFetchStrategyFactoryProtocol @@ -89,6 +90,15 @@ protocol PointOfSaleSearchingItemsControllerProtocol: PointOfSaleItemsController @MainActor private func loadRootItems() async { + // Temporay: Bypass product loading for screenshot tests + if ProcessInfo.processInfo.arguments.contains("bypass-pos-product-loading") { + let mockItems = Self.makeScreenshotMockItems() + itemsViewState.containerState = .content + itemsViewState.itemsStack = ItemsStackState(root: .loaded(mockItems, hasMoreItems: false), + itemStates: [:]) + return + } + do { try await paginationTracker.resync { [weak self] pageNumber in guard let self else { return true } @@ -324,3 +334,68 @@ private extension PointOfSaleItemsController { itemsViewState.itemsStack.itemStates = itemStates } } + +// MARK: - Screenshot Mock Data +private extension PointOfSaleItemsController { + /// Creates mock POSItems for screenshot tests + /// TODO: Move to ScreenshotsObjectGraph? Or similar. + static func makeScreenshotMockItems() -> [POSItem] { + let port = UserDefaults.standard.integer(forKey: "mocks-port") + let mockResourceUrlHost = "http://localhost:\(port)/" + + let product1 = POSSimpleProduct( + id: UUID(), + name: "Rose Gold Shades", + formattedPrice: "$35.00", + productImageSource: mockResourceUrlHost + "rose-gold-shades", + productID: 1, + price: "35.00", + manageStock: false, + stockQuantity: nil, + stockStatusKey: "instock" + ) + + let product2 = POSSimpleProduct( + id: UUID(), + name: "Black Coral Shades", + formattedPrice: "$45.00", + productImageSource: mockResourceUrlHost + "black-coral-shades", + productID: 2, + price: "45.00", + manageStock: false, + stockQuantity: nil, + stockStatusKey: "instock" + ) + + let product3 = POSSimpleProduct( + id: UUID(), + name: "Akoya Pearl Shades", + formattedPrice: "$50.00", + productImageSource: mockResourceUrlHost + "akoya-pearl-shades", + productID: 3, + price: "50.00", + manageStock: true, + stockQuantity: 10, + stockStatusKey: "instock" + ) + + let product4 = POSSimpleProduct( + id: UUID(), + name: "Malaya Shades", + formattedPrice: "$40.00", + productImageSource: mockResourceUrlHost + "malaya-shades", + productID: 4, + price: "40.00", + manageStock: false, + stockQuantity: nil, + stockStatusKey: "instock" + ) + + return [ + .simpleProduct(product1), + .simpleProduct(product2), + .simpleProduct(product3), + .simpleProduct(product4) + ] + } +} diff --git a/Modules/Sources/PointOfSale/Presentation/CartView.swift b/Modules/Sources/PointOfSale/Presentation/CartView.swift index 1c594cad998..49ad0e3cf3e 100644 --- a/Modules/Sources/PointOfSale/Presentation/CartView.swift +++ b/Modules/Sources/PointOfSale/Presentation/CartView.swift @@ -80,6 +80,7 @@ struct CartView: View { }) .background(backgroundColor.ignoresSafeArea(.all)) .accessibilityElement(children: .contain) + .accessibilityIdentifier("pos-cart-view") } } } @@ -175,6 +176,7 @@ private extension CartView { } .buttonStyle(POSFilledButtonStyle(size: .normal)) .disabled(CartViewHelper().hasUnresolvedItems(cart: posModel.cart)) + .accessibilityIdentifier("pos-checkout-button") } var backButtonConfiguration: POSPageHeaderBackButtonConfiguration? { diff --git a/Modules/Sources/PointOfSale/Presentation/Item Selector/ItemList.swift b/Modules/Sources/PointOfSale/Presentation/Item Selector/ItemList.swift index c3a08a3fd63..551cf0f56a2 100644 --- a/Modules/Sources/PointOfSale/Presentation/Item Selector/ItemList.swift +++ b/Modules/Sources/PointOfSale/Presentation/Item Selector/ItemList.swift @@ -156,6 +156,7 @@ struct ItemListRow: View { }, label: { SimpleProductCardView(product: product) }) + .accessibilityIdentifier("pos-product-card-\(product.productID)") case let .variableParentProduct(parentProduct): if #available(iOS 18.0, *) { NavigationLink(value: item) { diff --git a/Modules/Sources/PointOfSale/Presentation/TotalsView.swift b/Modules/Sources/PointOfSale/Presentation/TotalsView.swift index 7cd2e28f308..5a395209b78 100644 --- a/Modules/Sources/PointOfSale/Presentation/TotalsView.swift +++ b/Modules/Sources/PointOfSale/Presentation/TotalsView.swift @@ -57,8 +57,18 @@ struct TotalsView: View { CashPaymentButton( orderState: posModel.orderState, - paymentState: posModel.paymentState, + paymentState: posModel.paymentState, //idle cardReaderConnectionStatus: posModel.cardReaderConnectionStatus, + /* + (lldb) po posModel.cardReaderConnectionStatus + ▿ CardPresentPaymentReaderConnectionStatus + ▿ connected : CardPresentPaymentCardReader + - name : "Simulated POS E" + ▿ batteryLevel : Optional + - some : 0.5 + ▿ softwareVersion : Optional + - some : "1.00.03.34-SZZZ_Generic_v45-300001" + */ startCashPaymentAction: { await posModel.startCashPayment() } ) } @@ -67,6 +77,7 @@ struct TotalsView: View { case .error(.other(let message), let handler): PointOfSaleOrderSyncErrorMessageView(message: message, retryHandler: handler) .transition(.opacity) + case .error(.invalidCoupon(let message), let handler): PointOfSaleOrderSyncCouponsErrorMessageView(message: message, retryHandler: handler) .transition(.opacity) @@ -408,6 +419,7 @@ private struct TotalFieldView: View { } .accessibilityElement(children: .combine) .accessibilityAddTraits(.isHeader) + .accessibilityIdentifier("pos-total-field") .foregroundColor(Color.posOnSurface) } } diff --git a/Modules/Sources/UITestsFoundation/Screens/Orders/SingleOrderScreen.swift b/Modules/Sources/UITestsFoundation/Screens/Orders/SingleOrderScreen.swift index 124999a2d29..de58dc5b024 100644 --- a/Modules/Sources/UITestsFoundation/Screens/Orders/SingleOrderScreen.swift +++ b/Modules/Sources/UITestsFoundation/Screens/Orders/SingleOrderScreen.swift @@ -82,6 +82,7 @@ public final class SingleOrderScreen: ScreenObject { return try PaymentMethodsScreen() } + @discardableResult public func goBackToOrdersScreen() throws -> OrdersScreen { let orderDetailTableView = app.tables["order-details-table-view"] diff --git a/Modules/Sources/UITestsFoundation/Screens/POS/POSScreen.swift b/Modules/Sources/UITestsFoundation/Screens/POS/POSScreen.swift new file mode 100644 index 00000000000..9112cb5bd51 --- /dev/null +++ b/Modules/Sources/UITestsFoundation/Screens/POS/POSScreen.swift @@ -0,0 +1,53 @@ +import ScreenObject +import XCTest + +public final class POSScreen: ScreenObject { + + private let cartViewGetter: (XCUIApplication) -> XCUIElement = { + $0.otherElements["pos-cart-view"] + } + + public init(app: XCUIApplication = XCUIApplication()) throws { + try super.init( + expectedElementGetters: [cartViewGetter], + app: app + ) + } + + @discardableResult + public func tapAddProduct(productID: Int) -> Self { + let productButton = app.buttons["pos-product-card-\(productID)"] + + guard productButton.waitForExistence(timeout: 1) else { + return self + } + productButton.tap() + + return self + } + + @discardableResult + public func tapCheckout() -> Self { + let checkoutButton = app.buttons["pos-checkout-button"] + + guard checkoutButton.waitForExistence(timeout: 3) else { + return self + } + + checkoutButton.tap() + return self + } + + @discardableResult + public func waitForTotalsLoaded() -> Self { + // Wait for the actual totals to load (not shimmer/ghost state) + // This waits for orderState to be .loaded and payment to start + let totalField = app.otherElements["pos-total-field"] + + guard totalField.waitForExistence(timeout: 5) else { + return self + } + + return self + } +} diff --git a/Modules/Sources/UITestsFoundation/Screens/TabNavComponent.swift b/Modules/Sources/UITestsFoundation/Screens/TabNavComponent.swift index 833c156a0a3..69e4330f8f8 100644 --- a/Modules/Sources/UITestsFoundation/Screens/TabNavComponent.swift +++ b/Modules/Sources/UITestsFoundation/Screens/TabNavComponent.swift @@ -14,6 +14,10 @@ public final class TabNavComponent: ScreenObject { private let productsTabButtonGetter: (XCUIApplication) -> XCUIElement = { $0.tabBars.firstMatch.buttons["tab-bar-products-item"] } + + private let posTabButtonGetter: (XCUIApplication) -> XCUIElement = { + $0.tabBars.firstMatch.buttons["tab-bar-pos-item"] + } private let menuTabButtonGetter: (XCUIApplication) -> XCUIElement = { $0.tabBars.firstMatch.buttons["tab-bar-menu-item"] @@ -23,6 +27,7 @@ public final class TabNavComponent: ScreenObject { private var ordersTabButton: XCUIElement { ordersTabButtonGetter(app) } private var menuTabButton: XCUIElement { menuTabButtonGetter(app) } private var productsTabButton: XCUIElement { productsTabButtonGetter(app) } + private var posTabButton: XCUIElement { posTabButtonGetter(app) } public init(app: XCUIApplication = XCUIApplication()) throws { try super.init( @@ -53,6 +58,11 @@ public final class TabNavComponent: ScreenObject { return try ProductsScreen() } + public func goToPOSScreen() throws -> POSScreen { + posTabButton.tap() + return try POSScreen() + } + @discardableResult public func goToMenuScreen() throws -> MenuScreen { menuTabButton.tap() diff --git a/Modules/Sources/Yosemite/Model/Mocks/ActionHandlers/MockAppSettingsActionHandler.swift b/Modules/Sources/Yosemite/Model/Mocks/ActionHandlers/MockAppSettingsActionHandler.swift index 0c9f42c11eb..bdf3930493c 100644 --- a/Modules/Sources/Yosemite/Model/Mocks/ActionHandlers/MockAppSettingsActionHandler.swift +++ b/Modules/Sources/Yosemite/Model/Mocks/ActionHandlers/MockAppSettingsActionHandler.swift @@ -57,8 +57,13 @@ struct MockAppSettingsActionHandler: MockActionHandler { case .upsertProductsSettings(_, _, _, _, _, _, _, let onCompletion): onCompletion(nil) case .resetEligibilityErrorInfo, - .setTelemetryAvailability - : + .setTelemetryAvailability, + .getAppPasswordsExperimentSettingState, + .getPOSSurveyCurrentMerchantNotificationScheduled, + .getPOSSurveyPotentialMerchantNotificationScheduled, + .getHasPOSBeenOpenedAtLeastOnce, + .setHasPOSBeenOpenedAtLeastOnce, + .setPOSLastOpenedDate: break default: unimplementedAction(action: action) } diff --git a/Modules/Sources/Yosemite/Model/Mocks/ActionHandlers/MockCardPresentPaymentActionHandler.swift b/Modules/Sources/Yosemite/Model/Mocks/ActionHandlers/MockCardPresentPaymentActionHandler.swift index b9caf7fff10..9287d57a300 100644 --- a/Modules/Sources/Yosemite/Model/Mocks/ActionHandlers/MockCardPresentPaymentActionHandler.swift +++ b/Modules/Sources/Yosemite/Model/Mocks/ActionHandlers/MockCardPresentPaymentActionHandler.swift @@ -21,6 +21,8 @@ struct MockCardPresentPaymentActionHandler: MockActionHandler { onCompletion(true) case .observeConnectedReaders(let onCompletion): observeConnectedReaders(onCompletion: onCompletion) + case .observeCardReaderUpdateState(let onCompletion): + observeCardReaderUpdateState(onCompletion: onCompletion) case .collectPayment(_, _, _, let onCardReaderMessage, _, _): // This immediately brings up the `CardPresentModalTapCard` screen, which is used by // `WooCommerceScreenshots` to display it for screenshotting purpose. @@ -51,4 +53,8 @@ struct MockCardPresentPaymentActionHandler: MockActionHandler { let cardReaders = objectGraph.cardReaders onCompletion(cardReaders) } + + private func observeCardReaderUpdateState(onCompletion: @escaping (AnyPublisher) -> Void) { + onCompletion(Just(.none).eraseToAnyPublisher()) + } } diff --git a/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift b/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift index 481cff9b1fd..a5c629befa3 100644 --- a/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift +++ b/WooCommerce/Classes/POS/TabBar/POSTabCoordinator.swift @@ -3,6 +3,7 @@ import UIKit import SwiftUI import Yosemite import class WooFoundation.CurrencySettings +import WooFoundationCore import protocol Storage.GRDBManagerProtocol import protocol Storage.StorageManagerType import class WooFoundationCore.CurrencyFormatter @@ -222,11 +223,20 @@ private extension POSTabCoordinator { if let receiptService = POSReceiptService(siteID: siteID, credentials: credentials, selectedSite: defaultSitePublisher, - appPasswordSupportState: isAppPasswordSupported), - let orderService = POSOrderService(siteID: siteID, - credentials: credentials, - selectedSite: defaultSitePublisher, - appPasswordSupportState: isAppPasswordSupported) { + appPasswordSupportState: isAppPasswordSupported) { + + // Use mock order service for screenshot tests to bypass network calls + let orderService: POSOrderServiceProtocol + if ProcessConfiguration.shouldBypassPOSOrderSyncing { + orderService = POSOrderServiceScreenshotMock(currency: currencySettings.currencyCode.rawValue) + } else if let realService = POSOrderService(siteID: siteID, + credentials: credentials, + selectedSite: defaultSitePublisher, + appPasswordSupportState: isAppPasswordSupported) { + orderService = realService + } else { + return + } let posView = PointOfSaleEntryPointView( siteID: siteID, itemFetchStrategyFactory: posItemFetchStrategyFactory, @@ -290,3 +300,65 @@ private extension POSTabCoordinator { TracksProvider.setPOSMode(isPointOfSaleActive) } } + +/// Mock order service for screenshot tests that returns immediate loaded state +private final class POSOrderServiceScreenshotMock: POSOrderServiceProtocol { + private let currency: String + + init(currency: String) { + self.currency = currency + } + + func syncOrder(cart: POSCart, currency: CurrencyCode) async throws -> Order { + // Create a mock order with totals calculated from the cart + // For screenshot tests with 2 products: $35.00 + $45.00 = $80.00 + let subtotal = "80.00" + let tax = "0.00" + let total = "80.00" + + return Order(siteID: 0, + orderID: 1, + parentID: 0, + customerID: 0, + orderKey: "", + isEditable: true, + needsPayment: true, + needsProcessing: true, + number: "1", + status: .pending, + currency: currency.rawValue, + currencySymbol: "$", + customerNote: nil, + dateCreated: Date(), + dateModified: Date(), + datePaid: nil, + discountTotal: "0.00", + discountTax: "0.00", + shippingTotal: "0.00", + shippingTax: "0.00", + total: total, + totalTax: tax, + paymentMethodID: "", + paymentMethodTitle: "", + paymentURL: nil, + chargeID: nil, + items: [], + billingAddress: nil, + shippingAddress: nil, + shippingLines: [], + coupons: [], + refunds: [], + fees: [], + taxes: [], + customFields: [], + renewalSubscriptionID: nil, + appliedGiftCards: [], + attributionInfo: nil, + shippingLabels: [], + createdVia: "pos") + } + + func updatePOSOrder(orderID: Int64, recipientEmail: String) async throws {} + + func markOrderAsCompletedWithCashPayment(order: Order, changeDueAmount: String?) async throws {} +} diff --git a/WooCommerce/Classes/System/ProcessConfiguration.swift b/WooCommerce/Classes/System/ProcessConfiguration.swift index 96d089c5e64..735197aeb07 100644 --- a/WooCommerce/Classes/System/ProcessConfiguration.swift +++ b/WooCommerce/Classes/System/ProcessConfiguration.swift @@ -21,4 +21,19 @@ struct ProcessConfiguration { static var shouldSimulatePushNotification: Bool { ProcessInfo.processInfo.arguments.contains("-mocks-push-notification") } + + /// Returns `true` when POS eligibility checks should be bypassed for screenshot tests. + static var shouldBypassPOSEligibilityChecks: Bool { + ProcessInfo.processInfo.arguments.contains("bypass-pos-eligibility-checks") + } + + /// Returns `true` when POS product loading should be bypassed for screenshot tests. + static var shouldBypassPOSProductLoading: Bool { + ProcessInfo.processInfo.arguments.contains("bypass-pos-product-loading") + } + + /// Returns `true` when POS order syncing should be bypassed for screenshot tests. + static var shouldBypassPOSOrderSyncing: Bool { + ProcessInfo.processInfo.arguments.contains("bypass-pos-order-syncing") + } } diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityChecker.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityChecker.swift index 1ddce9341f2..92adfcf2d94 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityChecker.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/POS/POSTabEligibilityChecker.swift @@ -58,6 +58,11 @@ final class POSTabEligibilityChecker: POSEntryPointEligibilityCheckerProtocol { /// Determines whether the POS entry point can be shown based on the selected store and feature gates. func checkEligibility() async -> POSEligibilityState { + // Bypass eligibility checks for screenshot tests + if ProcessConfiguration.shouldBypassPOSEligibilityChecks { + return .eligible + } + async let siteSettingsEligibility = checkSiteSettingsEligibility() async let pluginEligibility = checkPluginEligibility() diff --git a/WooCommerce/WooCommerceScreenshots/WooCommerceScreenshots.swift b/WooCommerce/WooCommerceScreenshots/WooCommerceScreenshots.swift index 412d45fa493..8a4272f6a6a 100644 --- a/WooCommerce/WooCommerceScreenshots/WooCommerceScreenshots.swift +++ b/WooCommerce/WooCommerceScreenshots/WooCommerceScreenshots.swift @@ -27,6 +27,9 @@ class WooCommerceScreenshots: XCTestCase { app.launchArguments.append("-simulate-stripe-card-reader") app.launchArguments.append("disable-animations") app.launchArguments.append("-mocks-push-notification") + app.launchArguments.append("bypass-pos-eligibility-checks") + app.launchArguments.append("bypass-pos-product-loading") + app.launchArguments.append("bypass-pos-order-syncing") app.launchArguments.append(contentsOf: ["-mocks-port", "\(server.listenAddress.port)"]) app.launch() @@ -46,6 +49,16 @@ class WooCommerceScreenshots: XCTestCase { // The interruption monitor above only activates when the app receives user interaction. app.tap() + // POS + try TabNavComponent() + .goToPOSScreen() + .tapAddProduct(productID: 1) + .tapAddProduct(productID: 2) + .thenTakeScreenshot(named: "test-pos-screenshot", orientation: .landscapeLeft) + .tapCheckout() + .waitForTotalsLoaded() + .thenTakeScreenshot(named: "test-pos-screenshot-2", orientation: .landscapeLeft) + // My Store try TabNavComponent() .goToMyStoreScreen() @@ -150,7 +163,8 @@ fileprivate var screenshotCount = 0 extension BaseScreen { @MainActor @discardableResult - func thenTakeScreenshot(named title: String) -> Self { + func thenTakeScreenshot(named title: String, orientation: UIDeviceOrientation = .portrait) -> Self { + XCUIDevice.shared.orientation = orientation screenshotCount += 1 let mode = XCUIDevice.inDarkMode ? "dark" : "light" @@ -185,7 +199,8 @@ extension ScreenObject { } @MainActor @discardableResult - func thenTakeScreenshot(named title: String) -> Self { + func thenTakeScreenshot(named title: String, orientation: UIDeviceOrientation = .portrait) -> Self { + XCUIDevice.shared.orientation = orientation screenshotCount += 1 let mode = XCUIDevice.inDarkMode ? "dark" : "light"